@aeonsparx/pi-unreal 0.0.0-stage → 1.1.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 +89 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/docs/internals.md +245 -0
- package/extensions/unreal/index.ts +696 -0
- package/extensions/unreal/lib/cache.ts +190 -0
- package/extensions/unreal/lib/compiledb.ts +491 -0
- package/extensions/unreal/lib/descriptors.ts +631 -0
- package/extensions/unreal/lib/engine-resolver.ts +443 -0
- package/extensions/unreal/lib/pi-tools.ts +452 -0
- package/extensions/unreal/lib/plugin-resolver.ts +305 -0
- package/extensions/unreal/lib/query.ts +272 -0
- package/extensions/unreal/lib/registry.ts +208 -0
- package/extensions/unreal/lib/scan.ts +510 -0
- package/extensions/unreal/lib/store.ts +1490 -0
- package/extensions/unreal/lib/tools.ts +1074 -0
- package/extensions/unreal/lib/ubt.ts +380 -0
- package/extensions/unreal/lib/util.ts +210 -0
- package/package.json +60 -4
- package/prompts/unreal-context.md +36 -0
- package/skills/ue-engine-resolution/SKILL.md +129 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin discovery and reference resolution.
|
|
3
|
+
*
|
|
4
|
+
* Discovery order mirrors `UnrealBuildTool.System.ReadAvailablePlugins()`
|
|
5
|
+
* (`Engine/Source/Programs/UnrealBuildTool/System/Plugins.cs:322`):
|
|
6
|
+
* 1. Engine plugins — `<EngineRoot>/Engine/Plugins` (`EPluginType::Engine`)
|
|
7
|
+
* 2. Project plugins — `<ProjectDir>/Plugins` (`EPluginType::Project`)
|
|
8
|
+
* 3. Mods — `<ProjectDir>/Mods` (`EPluginType::Mod`)
|
|
9
|
+
* 4. Additional dirs — `.uproject` `AdditionalPluginDirectories` (`EPluginType::External`)
|
|
10
|
+
* Later scans win on a name collision: a project plugin shadows an engine plugin of the same
|
|
11
|
+
* name, which is exactly what a marketplace copy of an engine plugin does.
|
|
12
|
+
*
|
|
13
|
+
* Reference semantics mirror `PluginManager` (`Engine/Source/Runtime/Projects/Private/…`):
|
|
14
|
+
* a `.uproject` reference is `Enabled` / `Optional` / `Activate` (`PluginReferenceDescriptor.cpp:132-172`),
|
|
15
|
+
* and an engine-version mismatch is a warning, not an error (`FEngineVersion::Parse` +
|
|
16
|
+
* `GetNewest`, `PluginManager.cpp:2690-2712`).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { parseUplugin, versionToMajorMinor, type EngineVersion, type PluginReference, type UprojectDescriptor, type UpluginDescriptor } from "./descriptors.ts";
|
|
20
|
+
import { joinPath, normalizePath, realFs, shortHash, type FsAdapter } from "./util.ts";
|
|
21
|
+
|
|
22
|
+
export type PluginSource = "Engine" | "Project" | "Mod" | "External";
|
|
23
|
+
|
|
24
|
+
const SOURCE_RANK: Record<PluginSource, number> = { Engine: 1, Project: 2, Mod: 3, External: 4 };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Bucket order for one plugin name: the copy the engine actually loads comes first
|
|
28
|
+
* (Engine < Project < Mod < External), ties broken by path. Shared with the cache layer so a
|
|
29
|
+
* cache hit cannot return a different shadow winner than a cold scan.
|
|
30
|
+
*/
|
|
31
|
+
export function comparePluginSource(a: { source: PluginSource; path: string }, b: { source: PluginSource; path: string }): number {
|
|
32
|
+
return SOURCE_RANK[b.source] - SOURCE_RANK[a.source] || a.path.localeCompare(b.path);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Cached, compact view of a `.uplugin`. Full descriptor is re-parsed on demand from disk. */
|
|
36
|
+
export interface PluginSummary {
|
|
37
|
+
name: string;
|
|
38
|
+
path: string;
|
|
39
|
+
dir: string;
|
|
40
|
+
source: PluginSource;
|
|
41
|
+
version?: string;
|
|
42
|
+
semVersion?: string;
|
|
43
|
+
versionName?: string;
|
|
44
|
+
engineVersionRaw?: string;
|
|
45
|
+
category?: string;
|
|
46
|
+
friendlyName?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
enabledByDefault: boolean;
|
|
49
|
+
canContainContent: boolean;
|
|
50
|
+
isBetaVersion: boolean;
|
|
51
|
+
installed: boolean;
|
|
52
|
+
supportedTargetPlatforms: string[];
|
|
53
|
+
modules: { moduleName: string; type?: string; loadingPhase?: string }[];
|
|
54
|
+
plugins: { name: string; enabled: boolean; optional: boolean; activate: boolean }[];
|
|
55
|
+
diagnostics: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PluginIndex {
|
|
59
|
+
engineRoot?: string;
|
|
60
|
+
projectDir?: string;
|
|
61
|
+
additionalDirs: string[];
|
|
62
|
+
scannedDirs: string[];
|
|
63
|
+
scannedFiles: number;
|
|
64
|
+
entries: PluginSummary[];
|
|
65
|
+
byName: Map<string, PluginSummary[]>;
|
|
66
|
+
builtAtMs: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface BuildPluginIndexOptions {
|
|
70
|
+
engineRoot?: string;
|
|
71
|
+
projectDir?: string;
|
|
72
|
+
additionalPluginDirectories?: string[];
|
|
73
|
+
/** Extra roots treated as Project-type plugins (`.uproject` AdditionalRootDirectories). */
|
|
74
|
+
additionalRootDirectories?: string[];
|
|
75
|
+
fs?: FsAdapter;
|
|
76
|
+
now?: () => number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Scan roots in discovery order. Exported so the cache key and the builder agree on exactly
|
|
81
|
+
* which directories an index covers.
|
|
82
|
+
*/
|
|
83
|
+
export function computeScanDirs(options: BuildPluginIndexOptions): { dir: string; source: PluginSource }[] {
|
|
84
|
+
const specs: { dir: string; source: PluginSource }[] = [];
|
|
85
|
+
if (options.engineRoot) specs.push({ dir: joinPath(normalizePath(options.engineRoot), "Engine/Plugins"), source: "Engine" });
|
|
86
|
+
if (options.projectDir) {
|
|
87
|
+
specs.push({ dir: joinPath(normalizePath(options.projectDir), "Plugins"), source: "Project" });
|
|
88
|
+
specs.push({ dir: joinPath(normalizePath(options.projectDir), "Mods"), source: "Mod" });
|
|
89
|
+
}
|
|
90
|
+
for (const dir of options.additionalPluginDirectories ?? []) {
|
|
91
|
+
const resolved = normalizePath(dir);
|
|
92
|
+
specs.push({ dir: options.projectDir && dir.startsWith(".") ? joinPath(normalizePath(options.projectDir), dir) : resolved, source: "External" });
|
|
93
|
+
}
|
|
94
|
+
for (const dir of options.additionalRootDirectories ?? []) {
|
|
95
|
+
specs.push({ dir: joinPath(normalizePath(dir), "Plugins"), source: "External" });
|
|
96
|
+
}
|
|
97
|
+
return specs;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function scanUplugins(dir: string, source: PluginSource, fs: FsAdapter): Promise<PluginSummary[]> {
|
|
101
|
+
// fast-glob is imported lazily: an index rebuild is the only caller that needs it.
|
|
102
|
+
const { default: fastGlob } = await import("fast-glob");
|
|
103
|
+
let found: string[] = [];
|
|
104
|
+
try {
|
|
105
|
+
found = await fastGlob(["**/*.uplugin"], {
|
|
106
|
+
cwd: dir,
|
|
107
|
+
absolute: true,
|
|
108
|
+
dot: false,
|
|
109
|
+
followSymbolicLinks: false,
|
|
110
|
+
ignore: ["**/Binaries/**", "**/Intermediate/**", "**/Saved/**", "**/.git/**", "**/ThirdParty/**"],
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const out: PluginSummary[] = [];
|
|
116
|
+
for (const raw of found) {
|
|
117
|
+
const path = normalizePath(raw);
|
|
118
|
+
if (!fs.exists(path)) continue;
|
|
119
|
+
out.push(toSummary(parseUplugin(path, fs), source));
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function toSummary(descriptor: UpluginDescriptor, source: PluginSource): PluginSummary {
|
|
125
|
+
return {
|
|
126
|
+
name: descriptor.name,
|
|
127
|
+
path: descriptor.path,
|
|
128
|
+
dir: descriptor.path.slice(0, descriptor.path.lastIndexOf("/")),
|
|
129
|
+
source,
|
|
130
|
+
version: descriptor.version,
|
|
131
|
+
semVersion: descriptor.semVersion,
|
|
132
|
+
versionName: descriptor.versionName,
|
|
133
|
+
engineVersionRaw: descriptor.engineVersion?.raw,
|
|
134
|
+
category: descriptor.category,
|
|
135
|
+
friendlyName: descriptor.friendlyName,
|
|
136
|
+
description: descriptor.description,
|
|
137
|
+
enabledByDefault: descriptor.enabledByDefault,
|
|
138
|
+
canContainContent: descriptor.canContainContent,
|
|
139
|
+
isBetaVersion: descriptor.isBetaVersion,
|
|
140
|
+
installed: descriptor.installed,
|
|
141
|
+
supportedTargetPlatforms: descriptor.supportedTargetPlatforms,
|
|
142
|
+
modules: descriptor.modules.map((entry) => ({
|
|
143
|
+
moduleName: entry.moduleName,
|
|
144
|
+
type: entry.descriptor.type,
|
|
145
|
+
loadingPhase: entry.descriptor.loadingPhase,
|
|
146
|
+
})),
|
|
147
|
+
plugins: descriptor.plugins.map((ref) => ({ name: ref.name, enabled: ref.enabled, optional: ref.optional, activate: ref.activate })),
|
|
148
|
+
diagnostics: descriptor.diagnostics,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build the plugin index for one project.
|
|
154
|
+
*
|
|
155
|
+
* Cold cost on a real install is the engine scan (UE 5.8: 897 `.uplugin` files). The result is
|
|
156
|
+
* cached on disk by `cache.ts`, keyed on engine version + directory mtimes.
|
|
157
|
+
*/
|
|
158
|
+
export async function buildPluginIndex(options: BuildPluginIndexOptions): Promise<PluginIndex> {
|
|
159
|
+
const fs = options.fs ?? realFs;
|
|
160
|
+
const now = options.now ?? (() => Date.now());
|
|
161
|
+
const scanSpecs = computeScanDirs(options);
|
|
162
|
+
|
|
163
|
+
const entries: PluginSummary[] = [];
|
|
164
|
+
const scannedDirs: string[] = [];
|
|
165
|
+
for (const spec of scanSpecs) {
|
|
166
|
+
if (!fs.isDirectory(spec.dir)) continue;
|
|
167
|
+
scannedDirs.push(spec.dir);
|
|
168
|
+
entries.push(...(await scanUplugins(spec.dir, spec.source, fs)));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const byName = new Map<string, PluginSummary[]>();
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
const key = entry.name.toLowerCase();
|
|
174
|
+
const bucket = byName.get(key);
|
|
175
|
+
if (bucket) bucket.push(entry);
|
|
176
|
+
else byName.set(key, [entry]);
|
|
177
|
+
}
|
|
178
|
+
for (const bucket of byName.values()) {
|
|
179
|
+
bucket.sort(comparePluginSource);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
engineRoot: options.engineRoot ? normalizePath(options.engineRoot) : undefined,
|
|
184
|
+
projectDir: options.projectDir ? normalizePath(options.projectDir) : undefined,
|
|
185
|
+
additionalDirs: (options.additionalPluginDirectories ?? []).map(normalizePath),
|
|
186
|
+
scannedDirs,
|
|
187
|
+
scannedFiles: entries.length,
|
|
188
|
+
entries,
|
|
189
|
+
byName,
|
|
190
|
+
builtAtMs: now(),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface PluginReferenceResult {
|
|
195
|
+
name: string;
|
|
196
|
+
status: "resolved" | "disabled" | "missing" | "version-warning" | "shadowed" | "no-modules" | "beta";
|
|
197
|
+
/** Winning plugin, when the name resolves. */
|
|
198
|
+
plugin?: PluginSummary;
|
|
199
|
+
/** Same-name entries the winner displaced (`shadowed` lists them for transparency). */
|
|
200
|
+
shadowed: PluginSummary[];
|
|
201
|
+
/** Engine-version note mirroring `PluginManager`'s warning; absent when compatible. */
|
|
202
|
+
versionNote?: string;
|
|
203
|
+
notes: string[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Resolve every `Plugins` entry of a `.uproject` against the index. */
|
|
207
|
+
export function resolveProjectPlugins(
|
|
208
|
+
descriptor: UprojectDescriptor,
|
|
209
|
+
index: PluginIndex,
|
|
210
|
+
engineVersion: EngineVersion | undefined,
|
|
211
|
+
options: { targetPlatform?: string } = {},
|
|
212
|
+
): PluginReferenceResult[] {
|
|
213
|
+
const out: PluginReferenceResult[] = [];
|
|
214
|
+
for (const ref of descriptor.plugins) {
|
|
215
|
+
out.push(resolvePluginReference(ref, index, engineVersion, options));
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function resolvePluginReference(
|
|
221
|
+
ref: PluginReference,
|
|
222
|
+
index: PluginIndex,
|
|
223
|
+
engineVersion: EngineVersion | undefined,
|
|
224
|
+
options: { targetPlatform?: string } = {},
|
|
225
|
+
): PluginReferenceResult {
|
|
226
|
+
const bucket = index.byName.get(ref.name.toLowerCase()) ?? [];
|
|
227
|
+
const base = { name: ref.name, shadowed: bucket.slice(1), notes: [] as string[] } as PluginReferenceResult;
|
|
228
|
+
|
|
229
|
+
if (ref.supportedTargetPlatforms.length > 0 && options.targetPlatform && !ref.supportedTargetPlatforms.includes(options.targetPlatform)) {
|
|
230
|
+
base.notes.push(`reference lists SupportedTargetPlatforms [${ref.supportedTargetPlatforms.join(", ")}], not ${options.targetPlatform}`);
|
|
231
|
+
}
|
|
232
|
+
if (!ref.enabled) {
|
|
233
|
+
base.status = "disabled";
|
|
234
|
+
base.plugin = bucket[0];
|
|
235
|
+
base.notes.push("Enabled: false in the .uproject reference");
|
|
236
|
+
return base;
|
|
237
|
+
}
|
|
238
|
+
const winner = bucket[0];
|
|
239
|
+
if (!winner) {
|
|
240
|
+
base.status = "missing";
|
|
241
|
+
base.notes.push(
|
|
242
|
+
`no .uplugin named ${ref.name} under ${index.scannedDirs.join(" | ") || "(no plugin directories found)"}${ref.optional ? "; reference is Optional so the build tolerates it" : ""}`,
|
|
243
|
+
);
|
|
244
|
+
return base;
|
|
245
|
+
}
|
|
246
|
+
base.plugin = winner;
|
|
247
|
+
if (bucket.length > 1) {
|
|
248
|
+
base.notes.push(
|
|
249
|
+
`${winner.source} ${winner.name} shadows ${bucket
|
|
250
|
+
.slice(1)
|
|
251
|
+
.map((entry) => `${entry.source} ${entry.name}`)
|
|
252
|
+
.join(", ")} — the engine loads the first match found (Engine < Project < Mod < External)`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (winner.modules.length === 0) base.notes.push("plugin declares no modules");
|
|
256
|
+
if (winner.isBetaVersion) base.notes.push("IsBetaVersion: true (metadata only in UE 5.8: read at PluginDescriptor.cpp:371 and PluginDescriptor.cs:397, no engine/UBT beta gate found)");
|
|
257
|
+
/* Engine truth: `.uplugin` has no `Enabled` key (PluginDescriptor.cpp never reads it). Default
|
|
258
|
+
enablement is `EnabledByDefault` -> FPlugin::IsEnabledByDefault (PluginManager.cpp:421-431),
|
|
259
|
+
and a `"Enabled": true` reference is what loads a plugin that is not enabled by default — so a
|
|
260
|
+
plugin's own flag is information, never a warning. */
|
|
261
|
+
if (ref.enabled && winner.enabledByDefault && !ref.activate) {
|
|
262
|
+
base.notes.push("plugin is EnabledByDefault: true, so it loads even without this reference");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const versionNote = comparePluginEngineVersion(winner, engineVersion);
|
|
266
|
+
if (versionNote) base.notes.push(versionNote);
|
|
267
|
+
|
|
268
|
+
let status: PluginReferenceResult["status"] = "resolved";
|
|
269
|
+
if (winner.modules.length === 0) status = "no-modules";
|
|
270
|
+
else if (winner.isBetaVersion) status = "beta";
|
|
271
|
+
if (versionNote) status = "version-warning";
|
|
272
|
+
base.status = status;
|
|
273
|
+
if (versionNote) base.versionNote = versionNote;
|
|
274
|
+
return base;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Mirrors `PluginManager.cpp:2690-2712`: mismatch is a warning naming both sides. */
|
|
278
|
+
function comparePluginEngineVersion(plugin: PluginSummary, engineVersion: EngineVersion | undefined): string | undefined {
|
|
279
|
+
if (!plugin.engineVersionRaw || !engineVersion) return undefined;
|
|
280
|
+
const declared = versionToMajorMinor(parseEngineVersionRaw(plugin.engineVersionRaw));
|
|
281
|
+
const current = versionToMajorMinor(engineVersion);
|
|
282
|
+
if (!declared || !current) return undefined;
|
|
283
|
+
if (declared === current) return undefined;
|
|
284
|
+
return `plugin EngineVersion ${plugin.engineVersionRaw} differs from engine ${engineVersion.raw} (PluginManager warns on load)`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parseEngineVersionRaw(raw: string): EngineVersion | undefined {
|
|
288
|
+
const match = raw.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-(\d+))?(?:\+(.*))?$/);
|
|
289
|
+
if (!match) return undefined;
|
|
290
|
+
const [, major, minor, patch, changelist, branch] = match;
|
|
291
|
+
return {
|
|
292
|
+
major: Number(major),
|
|
293
|
+
minor: Number(minor),
|
|
294
|
+
patch: Number(patch),
|
|
295
|
+
changelist: Number(changelist ?? "0"),
|
|
296
|
+
branchName: branch,
|
|
297
|
+
raw: raw.trim(),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Stable cache key inputs for an index build. */
|
|
302
|
+
export function indexSignature(index: PluginIndex): string {
|
|
303
|
+
const parts = [index.engineRoot ?? "-", index.projectDir ?? "-", index.scannedDirs.join(","), String(index.entries.length)];
|
|
304
|
+
return shortHash(parts.join("|"));
|
|
305
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UBT Query orchestration: derive a target, cache the two Query JSON documents, and hand the parsed
|
|
3
|
+
* payload to the index layer.
|
|
4
|
+
*
|
|
5
|
+
* Two measurements drive this file:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Query is expensive and asked for repeatedly.** `TargetDetails` for `LpEditor Win64 Development`
|
|
8
|
+
* costs ~6 s and 40 MB of JSON; `TargetModuleReferences` ~3 s. Agents ask the same build question
|
|
9
|
+
* dozens of times per session, so the JSON is cached in the pi cache dir
|
|
10
|
+
* (`<cache>/query/<target>.<platform>.<config>/`), never in the project tree (1.5 GB of build
|
|
11
|
+
* output is somebody else's problem) and never in git.
|
|
12
|
+
*
|
|
13
|
+
* 2. **`-NoIntellisense` voids the payload.** Measured: `dirToModule` survives, every
|
|
14
|
+
* `moduleToCompileSettings` entry (defines, include paths, compiler args) and every `modules`
|
|
15
|
+
* entry (source file lists) becomes `null`. The intellisense pass is what computes them, so this
|
|
16
|
+
* file never passes that flag — the first run pays ~17 s, every later run pays 0.
|
|
17
|
+
*
|
|
18
|
+
* A target never has to be typed: `pickTarget` derives it from the `.uproject` `Modules` section,
|
|
19
|
+
* then plugin allowlists, then `*.Target.cs` files, then the engine editor target, and reports which
|
|
20
|
+
* rule matched.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { joinPath, normalizePath, parentDir, baseName, realFs, type FsAdapter } from "./util.ts";
|
|
25
|
+
import { cacheDir } from "./cache.ts";
|
|
26
|
+
import { parseUproject } from "./descriptors.ts";
|
|
27
|
+
import { findProjectFile } from "./engine-resolver.ts";
|
|
28
|
+
import { discoverTargets, hostPlatform, parseModuleReferences, parseTargetDetails, pickDefaultTarget, readQueryJson, runQuery, type QueryName, type UbtModuleRefs, type TargetDetails } from "./ubt.ts";
|
|
29
|
+
|
|
30
|
+
export interface TargetChoice {
|
|
31
|
+
target: string;
|
|
32
|
+
platform: string;
|
|
33
|
+
configuration: string;
|
|
34
|
+
/** Which rule produced the target — printed so a wrong guess is visible, not silent. */
|
|
35
|
+
source: "explicit" | "uproject-modules" | "plugin-allowlist" | "target-file" | "engine-editor" | "host-default";
|
|
36
|
+
notes: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CachePaths {
|
|
40
|
+
root: string;
|
|
41
|
+
targetDetails: string;
|
|
42
|
+
moduleReferences: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface QueryCacheState {
|
|
46
|
+
paths: CachePaths;
|
|
47
|
+
/** True when both documents exist and carry a payload (not the `-NoIntellisense` skeleton). */
|
|
48
|
+
usable: boolean;
|
|
49
|
+
targetDetails?: { path: string; bytes: number; ageMs: number };
|
|
50
|
+
moduleReferences?: { path: string; bytes: number; ageMs: number };
|
|
51
|
+
notes: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface EnsureQueryInput {
|
|
55
|
+
cacheRoot?: string;
|
|
56
|
+
choice: TargetChoice;
|
|
57
|
+
engineRoot?: string;
|
|
58
|
+
projectPath?: string;
|
|
59
|
+
refresh?: boolean;
|
|
60
|
+
fs?: FsAdapter;
|
|
61
|
+
onStdout?: (chunk: string) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface EnsureQueryResult {
|
|
65
|
+
paths?: CachePaths;
|
|
66
|
+
source: "cache" | "query" | "missing";
|
|
67
|
+
details?: TargetDetails;
|
|
68
|
+
refs?: Map<string, UbtModuleRefs>;
|
|
69
|
+
durationsMs: { targetDetails?: number; moduleReferences?: number };
|
|
70
|
+
notes: string[];
|
|
71
|
+
warnings: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ───────────────────────────────────────────────────────────── target derivation */
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Derive (target, platform, configuration). Order of rules, first hit wins; the winning rule is
|
|
78
|
+
* reported. Explicit arguments always win, so a caller that knows better is never argued with.
|
|
79
|
+
*/
|
|
80
|
+
export function pickTarget(options: { cwd: string; target?: string; platform?: string; configuration?: string; engineRoot?: string; fs?: FsAdapter }): TargetChoice {
|
|
81
|
+
const fs = options.fs ?? realFs;
|
|
82
|
+
const platform = options.platform ?? hostPlatform();
|
|
83
|
+
const configuration = options.configuration ?? "Development";
|
|
84
|
+
const notes: string[] = [];
|
|
85
|
+
const projectPath = findProjectFile(options.cwd, fs);
|
|
86
|
+
const projectDir = projectPath ? parentDir(projectPath) : undefined;
|
|
87
|
+
const projectName = projectPath ? baseName(projectPath).replace(/\.uproject$/i, "") : undefined;
|
|
88
|
+
const targets = discoverTargets({ projectDir, engineRoot: options.engineRoot, projectDepth: 3, fs });
|
|
89
|
+
|
|
90
|
+
if (options.target) {
|
|
91
|
+
const known = targets.some((t) => t.name.toLowerCase() === options.target!.toLowerCase());
|
|
92
|
+
if (!known && projectDir) notes.push(`target '${options.target}' has no *.Target.cs under ${projectDir} (projectDir searched; a plugin or engine target may supply it)`);
|
|
93
|
+
return { target: options.target, platform, configuration, source: "explicit", notes };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 1. The .uproject Modules section: a project module's editor target is the normal answer.
|
|
97
|
+
if (projectPath) {
|
|
98
|
+
const descriptor = parseUproject(projectPath, fs);
|
|
99
|
+
const modules = descriptor.modules.map((m) => m.moduleName);
|
|
100
|
+
if (modules.length) {
|
|
101
|
+
const editor = targets.find((t) => t.kind === "project" && modules.some((m) => t.name === `${m}Editor`) || t.kind === "project" && /editor$/i.test(t.name));
|
|
102
|
+
const exact = targets.find((t) => t.kind === "project" && modules.includes(t.name));
|
|
103
|
+
const chosen = editor ?? exact;
|
|
104
|
+
if (chosen) {
|
|
105
|
+
notes.push(`.uproject Modules = [${modules.join(", ")}] -> ${chosen.name}`);
|
|
106
|
+
return { target: chosen.name, platform, configuration, source: "uproject-modules", notes };
|
|
107
|
+
}
|
|
108
|
+
notes.push(`.uproject Modules = [${modules.join(", ")}] but no matching *.Target.cs under ${projectDir ?? "?"}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. Plugin allowlists: an enabled plugin whose name matches a project target.
|
|
112
|
+
const enabled = descriptor.plugins.filter((p) => p.enabled !== false).map((p) => p.name);
|
|
113
|
+
const fromPlugin = targets.find((t) => t.kind === "project" && enabled.some((name) => t.name.toLowerCase().startsWith(name.toLowerCase())));
|
|
114
|
+
if (fromPlugin) {
|
|
115
|
+
notes.push(`matched enabled plugin -> ${fromPlugin.name}`);
|
|
116
|
+
return { target: fromPlugin.name, platform, configuration, source: "plugin-allowlist", notes };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Any project target, then the engine editor target, then a host-specific guess.
|
|
121
|
+
const projectTarget = pickDefaultTarget(targets.filter((t) => t.kind === "project"), projectName);
|
|
122
|
+
if (projectTarget) {
|
|
123
|
+
notes.push(`no .uproject Modules match; picked ${projectTarget.name} from *.Target.cs`);
|
|
124
|
+
return { target: projectTarget.name, platform, configuration, source: "target-file", notes };
|
|
125
|
+
}
|
|
126
|
+
const engineEditor = targets.find((t) => t.kind === "engine" && /editor/i.test(t.name));
|
|
127
|
+
if (engineEditor) {
|
|
128
|
+
notes.push(`no project target; engine editor target ${engineEditor.name}`);
|
|
129
|
+
return { target: engineEditor.name, platform, configuration, source: "engine-editor", notes };
|
|
130
|
+
}
|
|
131
|
+
// A guessed project target has no *.Target.cs to back it, so UBT would fail: report the
|
|
132
|
+
// guess, and make the failure mode explicit in the note.
|
|
133
|
+
const guess = `${projectName ?? "Target"}Editor`;
|
|
134
|
+
notes.push(`no *.Target.cs found for this project; guessed ${guess} — if UBT reports an unknown target, pass 'target' explicitly`);
|
|
135
|
+
return { target: guess, platform, configuration, source: "host-default", notes };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ──────────────────────────────────────────────────────────────────── the cache */
|
|
139
|
+
|
|
140
|
+
export function queryCachePaths(choice: TargetChoice, root = cacheDir()): CachePaths {
|
|
141
|
+
const dir = joinPath(root, "query", `${choice.target}.${choice.platform}.${choice.configuration}`);
|
|
142
|
+
return { root: dir, targetDetails: joinPath(dir, "TargetDetails.json"), moduleReferences: joinPath(dir, "TargetModuleReferences.json") };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Payload check, not existence check: a `-NoIntellisense` document exists and is worthless. */
|
|
146
|
+
function payloadOk(path: string, query: QueryName): boolean {
|
|
147
|
+
if (!existsSync(path)) return false;
|
|
148
|
+
try {
|
|
149
|
+
const json = readQueryJson(path) as Record<string, unknown> | undefined;
|
|
150
|
+
if (!json || typeof json !== "object") return false;
|
|
151
|
+
if (query === "TargetDetails") {
|
|
152
|
+
const settings = json.moduleToCompileSettings as Record<string, unknown> | undefined;
|
|
153
|
+
return !!settings && Object.keys(settings).length > 0;
|
|
154
|
+
}
|
|
155
|
+
const modules = json.modules as Record<string, unknown> | undefined;
|
|
156
|
+
return !!modules && Object.keys(modules).length > 0;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function describe(path: string) {
|
|
163
|
+
const stat = statSync(path);
|
|
164
|
+
return { path, bytes: stat.size, ageMs: Math.max(0, Date.now() - stat.mtimeMs) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function queryCacheState(root: string, choice: TargetChoice, fs: FsAdapter = realFs): QueryCacheState {
|
|
168
|
+
void fs;
|
|
169
|
+
const paths = queryCachePaths(choice, root);
|
|
170
|
+
const notes: string[] = [];
|
|
171
|
+
const targetDetails = existsSync(paths.targetDetails) ? describe(paths.targetDetails) : undefined;
|
|
172
|
+
const moduleReferences = existsSync(paths.moduleReferences) ? describe(paths.moduleReferences) : undefined;
|
|
173
|
+
const detailsOk = targetDetails ? payloadOk(paths.targetDetails, "TargetDetails") : false;
|
|
174
|
+
const refsOk = moduleReferences ? payloadOk(paths.moduleReferences, "TargetModuleReferences") : false;
|
|
175
|
+
if (targetDetails && !detailsOk) notes.push(`cached TargetDetails has no moduleToCompileSettings payload — it was produced with -NoIntellisense, refresh it`);
|
|
176
|
+
if (moduleReferences && !refsOk) notes.push(`cached TargetModuleReferences has no modules payload — it was produced with -NoIntellisense, refresh it`);
|
|
177
|
+
if (!targetDetails || !moduleReferences) notes.push(`query cache empty for ${choice.target} ${choice.platform} ${choice.configuration}; the first Query costs ~17 s`);
|
|
178
|
+
return { paths, usable: detailsOk && refsOk, targetDetails, moduleReferences, notes };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Return parsed Query data, running UBT only when the cache cannot serve the request.
|
|
183
|
+
* `refresh` is the only way to re-run a usable cache; a missing cache is always filled.
|
|
184
|
+
*/
|
|
185
|
+
export async function ensureQueryJson(input: EnsureQueryInput): Promise<EnsureQueryResult> {
|
|
186
|
+
const fs = input.fs ?? realFs;
|
|
187
|
+
const root = input.cacheRoot ?? cacheDir(fs);
|
|
188
|
+
const paths = queryCachePaths(input.choice, root);
|
|
189
|
+
const notes: string[] = [];
|
|
190
|
+
const warnings: string[] = [];
|
|
191
|
+
const durationsMs: { targetDetails?: number; moduleReferences?: number } = {};
|
|
192
|
+
const state = queryCacheState(root, input.choice, fs);
|
|
193
|
+
notes.push(...state.notes);
|
|
194
|
+
|
|
195
|
+
if (state.usable && !input.refresh) {
|
|
196
|
+
const details = parseTargetDetails(readQueryJson(paths.targetDetails));
|
|
197
|
+
const refs = parseModuleReferences(readQueryJson(paths.moduleReferences));
|
|
198
|
+
notes.push(`query cache hit: ${Math.round((state.targetDetails?.bytes ?? 0) / 1024)} KB + ${Math.round((state.moduleReferences?.bytes ?? 0) / 1024)} KB, age ${humanAge(state.targetDetails?.ageMs ?? 0)}`);
|
|
199
|
+
return { paths, source: "cache", details, refs, durationsMs, notes, warnings };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!input.engineRoot) {
|
|
203
|
+
notes.push("no engine root resolved for this project, so Query cannot run");
|
|
204
|
+
return { source: "missing", durationsMs, notes, warnings };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const identity = {
|
|
208
|
+
target: input.choice.target,
|
|
209
|
+
platform: input.choice.platform,
|
|
210
|
+
configuration: input.choice.configuration,
|
|
211
|
+
projectPath: input.projectPath,
|
|
212
|
+
};
|
|
213
|
+
try {
|
|
214
|
+
mkdirSync(paths.root, { recursive: true });
|
|
215
|
+
} catch (error) {
|
|
216
|
+
warnings.push(`cannot create ${paths.root}: ${error instanceof Error ? error.message : String(error)}`);
|
|
217
|
+
return { source: "missing", durationsMs, notes, warnings };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const run = async (query: QueryName, dest: string) => {
|
|
221
|
+
const result = await runQuery({ ...identity, query, engineRoot: input.engineRoot!, outputPath: dest, fs, onStdout: input.onStdout });
|
|
222
|
+
durationsMs[query === "TargetDetails" ? "targetDetails" : "moduleReferences"] = result.durationMs;
|
|
223
|
+
if (result.json === undefined) {
|
|
224
|
+
warnings.push(`${query} failed (exit ${result.exitCode}, ${humanAge(result.durationMs)}): ${result.notes.join(" ")}`);
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
if (normalizePath(result.outputPath) !== normalizePath(dest)) {
|
|
228
|
+
// UBT wrote where it wanted (its `Writing <path>` line); mirror it into the cache.
|
|
229
|
+
try {
|
|
230
|
+
mkdirSync(parentDir(dest) ?? paths.root, { recursive: true });
|
|
231
|
+
writeFileSync(dest, readFileSync(result.outputPath, "utf8"), "utf8");
|
|
232
|
+
} catch (error) {
|
|
233
|
+
warnings.push(`${query}: cached copy failed (${error instanceof Error ? error.message : String(error)}); using ${result.outputPath}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const ok = payloadOk(dest, query) || payloadOk(result.outputPath, query);
|
|
237
|
+
if (!ok) warnings.push(`${query} produced JSON without a payload — UBT ran with intellisense disabled; rerun with refresh`);
|
|
238
|
+
notes.push(`${query}: ${humanAge(result.durationMs)} via ${result.ubtPath}`);
|
|
239
|
+
return result.json;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const detailsJson = await run("TargetDetails", paths.targetDetails);
|
|
243
|
+
if (detailsJson === undefined) return { source: "missing", durationsMs, notes, warnings };
|
|
244
|
+
const refsJson = await run("TargetModuleReferences", paths.moduleReferences);
|
|
245
|
+
|
|
246
|
+
const details = parseTargetDetails(detailsJson);
|
|
247
|
+
const refs = parseModuleReferences(refsJson ?? {});
|
|
248
|
+
return { paths, source: "query", details, refs, durationsMs, notes, warnings };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Compact rendering of a target choice, for tool output and the context card. */
|
|
252
|
+
export function targetReport(choice: TargetChoice, state: QueryCacheState) {
|
|
253
|
+
return {
|
|
254
|
+
target: choice.target,
|
|
255
|
+
platform: choice.platform,
|
|
256
|
+
configuration: choice.configuration,
|
|
257
|
+
derivedFrom: choice.source,
|
|
258
|
+
notes: choice.notes,
|
|
259
|
+
cache: { dir: state.paths.root, usable: state.usable, targetDetailsBytes: state.targetDetails?.bytes, moduleReferencesBytes: state.moduleReferences?.bytes, age: state.targetDetails ? humanAge(state.targetDetails.ageMs) : undefined, notes: state.notes },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function humanAge(ageMs: number): string {
|
|
264
|
+
if (!Number.isFinite(ageMs)) return "unknown";
|
|
265
|
+
const seconds = ageMs / 1000;
|
|
266
|
+
if (seconds < 90) return `${Math.round(seconds)}s`;
|
|
267
|
+
const minutes = seconds / 60;
|
|
268
|
+
if (minutes < 90) return `${Math.round(minutes)}min`;
|
|
269
|
+
const hours = minutes / 60;
|
|
270
|
+
if (hours < 48) return `${Math.round(hours)}h`;
|
|
271
|
+
return `${Math.round(hours / 24)}d`;
|
|
272
|
+
}
|