@aeonsparx/pi-unreal 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +202 -2
- package/docs/internals.md +184 -0
- package/extensions/unreal/index.ts +682 -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 +402 -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 +900 -0
- package/extensions/unreal/lib/tools.ts +614 -0
- package/extensions/unreal/lib/ubt.ts +373 -0
- package/extensions/unreal/lib/util.ts +210 -0
- package/package.json +59 -4
- package/prompts/unreal-context.md +26 -0
- package/skills/ue-engine-resolution/SKILL.md +96 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool layer: turns the resolver/index/query libraries into the calls the `unreal_*` tools make.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a plain function (no pi types) so `tmp/smoke.mjs` can exercise the same code
|
|
5
|
+
* paths the tools do. Two invariants matter for how the tools behave:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Indexing is opt-in and scoped.** A definitions index over the project + its plugins is a
|
|
8
|
+
* few seconds; one over `Engine/Source/{Runtime,Editor,Developer}` is minutes. The engine index
|
|
9
|
+
* is only built when the caller asks for `scope: "engine"` / `"all"`, and says so in `notes`.
|
|
10
|
+
* 2. **Module attribution prefers UBT's own answer.** When Query JSON exists, `dirToModule` decides
|
|
11
|
+
* which module owns a directory; `*.Build.cs` location is the fallback (`meta.source`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parseUproject, type EngineVersion } from "./descriptors.ts";
|
|
15
|
+
import { cacheDir } from "./cache.ts";
|
|
16
|
+
import { buildPluginIndex, resolveProjectPlugins } from "./plugin-resolver.ts";
|
|
17
|
+
import { findProjectFile, resolveEngineForProject, type ResolverDeps } from "./engine-resolver.ts";
|
|
18
|
+
import { ensureQueryJson, pickTarget, queryCacheState, humanAge, type CachePaths, type TargetChoice } from "./query.ts";
|
|
19
|
+
import {
|
|
20
|
+
assembleRoots,
|
|
21
|
+
buildIndex,
|
|
22
|
+
contextFor,
|
|
23
|
+
findFileBySuffix,
|
|
24
|
+
indexKeyFor,
|
|
25
|
+
listFiles,
|
|
26
|
+
listIndexes,
|
|
27
|
+
listModules,
|
|
28
|
+
listSymbols,
|
|
29
|
+
lookupSymbol,
|
|
30
|
+
moduleFromBuildCs,
|
|
31
|
+
pickIndex,
|
|
32
|
+
readDefines,
|
|
33
|
+
symbolsInFile,
|
|
34
|
+
type ContextUnit,
|
|
35
|
+
type IndexState,
|
|
36
|
+
type LookupResult,
|
|
37
|
+
type IndexScope,
|
|
38
|
+
type ScanRoot,
|
|
39
|
+
DEFAULT_ENGINE_AREAS,
|
|
40
|
+
} from "./store.ts";
|
|
41
|
+
import { hostPlatform, parseModuleReferences, parseTargetDetails, readQueryJson, type UbtModuleRefs, type UbtModuleSettings } from "./ubt.ts";
|
|
42
|
+
import { discoverTargets, pickDefaultTarget } from "./ubt.ts";
|
|
43
|
+
import { clangdCheck, ensureCompileDb } from "./compiledb.ts";
|
|
44
|
+
import { baseName, joinPath, normalizePath, realFs, type FsAdapter } from "./util.ts";
|
|
45
|
+
|
|
46
|
+
/** Where the project, its engine, and its enabled plugins live. */
|
|
47
|
+
export interface Workspace {
|
|
48
|
+
cwd: string;
|
|
49
|
+
projectPath?: string;
|
|
50
|
+
projectDir?: string;
|
|
51
|
+
projectName?: string;
|
|
52
|
+
engineRoot?: string;
|
|
53
|
+
engineVersion?: EngineVersion;
|
|
54
|
+
/** Directories of plugins the `.uproject` enables, used as index roots. */
|
|
55
|
+
pluginDirs: string[];
|
|
56
|
+
notes: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function resolveWorkspace(cwd: string, fs: FsAdapter = realFs, deps: ResolverDeps = {}): Promise<Workspace> {
|
|
60
|
+
const notes: string[] = [];
|
|
61
|
+
const workspace: Workspace = { cwd: normalizePath(cwd), pluginDirs: [], notes };
|
|
62
|
+
|
|
63
|
+
const projectPath = findProjectFile(workspace.cwd, fs, 4);
|
|
64
|
+
if (!projectPath) {
|
|
65
|
+
notes.push("no *.uproject found within 4 levels of the working directory");
|
|
66
|
+
return workspace;
|
|
67
|
+
}
|
|
68
|
+
workspace.projectPath = projectPath;
|
|
69
|
+
workspace.projectDir = normalizePath(projectPath.slice(0, projectPath.length - baseName(projectPath).length).replace(/\/$/, ""));
|
|
70
|
+
workspace.projectName = baseName(projectPath).replace(/\.uproject$/i, "");
|
|
71
|
+
|
|
72
|
+
const resolution = await resolveEngineForProject(projectPath, { fs, ...deps });
|
|
73
|
+
if (resolution.status === "resolved" && resolution.engineRoot) {
|
|
74
|
+
workspace.engineRoot = resolution.engineRoot;
|
|
75
|
+
workspace.engineVersion = resolution.version;
|
|
76
|
+
notes.push(`engine ${resolution.version?.raw ?? resolution.identifier ?? "?"} at ${resolution.engineRoot} (${resolution.source}, ${resolution.installedBuild ? "installed" : "source"} build)`);
|
|
77
|
+
} else {
|
|
78
|
+
notes.push(`engine ${resolution.status}: ${resolution.diagnostics[0] ?? "see unreal_resolve_engine"}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const descriptor = parseUproject(projectPath, fs);
|
|
83
|
+
const index = await buildPluginIndex({
|
|
84
|
+
engineRoot: workspace.engineRoot,
|
|
85
|
+
projectDir: workspace.projectDir,
|
|
86
|
+
additionalPluginDirectories: descriptor.additionalPluginDirectories,
|
|
87
|
+
additionalRootDirectories: descriptor.additionalRootDirectories,
|
|
88
|
+
fs,
|
|
89
|
+
});
|
|
90
|
+
const refs = resolveProjectPlugins(descriptor, index, workspace.engineVersion);
|
|
91
|
+
workspace.pluginDirs = refs.filter((ref) => ref.plugin).map((ref) => normalizePath(ref.plugin!.dir));
|
|
92
|
+
const missing = refs.filter((ref) => ref.status === "missing").map((ref) => ref.name);
|
|
93
|
+
if (missing.length) notes.push(`plugin references with no descriptor: ${missing.join(", ")}`);
|
|
94
|
+
notes.push(`${workspace.pluginDirs.length} enabled plugin reference(s) resolved for index roots`);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
notes.push(`plugin resolution failed: ${(error as Error).message}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return workspace;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type Scope = "project" | "engine" | "all";
|
|
103
|
+
export type Mode = "definitions" | "full";
|
|
104
|
+
|
|
105
|
+
export interface IndexRequest {
|
|
106
|
+
workspace: Workspace;
|
|
107
|
+
mode: Mode;
|
|
108
|
+
scope: Exclude<Scope, "all">;
|
|
109
|
+
target?: string;
|
|
110
|
+
platform?: string;
|
|
111
|
+
configuration?: string;
|
|
112
|
+
/** Query JSON paths for `mode: "full"`; obtained from `ensureQueryJson`. */
|
|
113
|
+
queryFiles?: CachePaths;
|
|
114
|
+
refresh?: boolean;
|
|
115
|
+
engineAreas?: string[];
|
|
116
|
+
/** Default false: a lookup never blocks on a minutes-long engine scan. Only `unreal_index` opts in. */
|
|
117
|
+
allowEngineBuild?: boolean;
|
|
118
|
+
onProgress?: (message: string) => void;
|
|
119
|
+
fs?: FsAdapter;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface IndexHandle {
|
|
123
|
+
key: string;
|
|
124
|
+
index?: IndexState;
|
|
125
|
+
built: boolean;
|
|
126
|
+
durationMs?: number;
|
|
127
|
+
warnings: string[];
|
|
128
|
+
notes: string[];
|
|
129
|
+
roots: ScanRoot[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Load a matching index, building it when absent (or when `refresh` is set). */
|
|
133
|
+
export async function ensureIndex(request: IndexRequest): Promise<IndexHandle> {
|
|
134
|
+
const fs = request.fs ?? realFs;
|
|
135
|
+
const workspace = request.workspace;
|
|
136
|
+
const engineScope = request.scope === "engine";
|
|
137
|
+
const roots = assembleRoots({
|
|
138
|
+
projectRoot: engineScope ? undefined : workspace.projectDir,
|
|
139
|
+
pluginDirs: engineScope ? [] : workspace.pluginDirs,
|
|
140
|
+
engineRoot: engineScope ? workspace.engineRoot : undefined,
|
|
141
|
+
includeEngine: engineScope,
|
|
142
|
+
engineAreas: engineScope ? request.engineAreas : undefined,
|
|
143
|
+
fs,
|
|
144
|
+
});
|
|
145
|
+
const scope: IndexScope = engineScope ? "Engine" : "Project";
|
|
146
|
+
const notes = [...roots.notes];
|
|
147
|
+
if (!roots.roots.length) {
|
|
148
|
+
return { key: "none", built: false, warnings: [`no C++ module roots for scope '${request.scope}'`], notes, roots: [] };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const key = indexKeyFor({ mode: request.mode, roots: roots.roots.map((root) => root.dir), scope, target: request.target, platform: request.platform, configuration: request.configuration });
|
|
152
|
+
if (!request.refresh) {
|
|
153
|
+
const existing = pickIndex({ key, mode: request.mode, scope, projectRoot: engineScope ? undefined : workspace.projectDir, engineRoot: engineScope ? workspace.engineRoot : undefined, roots: roots.roots.map((root) => root.dir) }).index;
|
|
154
|
+
if (existing) {
|
|
155
|
+
notes.push(`using cached index ${existing.key} (${existing.meta.counts.symbols} symbols, ${humanAge(Date.now() - Date.parse(existing.meta.generatedAt))} old)`);
|
|
156
|
+
return { key, index: existing, built: false, warnings: [], notes, roots: roots.roots };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (engineScope && !request.allowEngineBuild) {
|
|
161
|
+
const areas = (request.engineAreas?.length ? request.engineAreas : DEFAULT_ENGINE_AREAS).join(", ");
|
|
162
|
+
notes.push(`no engine index for ${areas}. Building one scans every .h/.cpp in those areas (tens of thousands of files): minutes the first time, cached afterwards.`);
|
|
163
|
+
notes.push("call unreal_index with scope \"engine\" (engineAreas narrows the scan, e.g. [\"Runtime\"]), then re-run this lookup");
|
|
164
|
+
return { key, built: false, warnings: [], notes, roots: roots.roots };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const scopeNote = request.scope === "engine" ? "indexing Engine/Source (first engine index takes minutes, then it is cached)" : "indexing project and plugin sources";
|
|
168
|
+
notes.push(scopeNote);
|
|
169
|
+
request.onProgress?.(scopeNote);
|
|
170
|
+
const started = Date.now();
|
|
171
|
+
const build = await buildIndex({
|
|
172
|
+
key,
|
|
173
|
+
mode: request.mode,
|
|
174
|
+
roots: roots.roots,
|
|
175
|
+
engineRoot: workspace.engineRoot,
|
|
176
|
+
projectRoot: workspace.projectDir,
|
|
177
|
+
projectName: workspace.projectName,
|
|
178
|
+
target: request.target,
|
|
179
|
+
platform: request.platform,
|
|
180
|
+
configuration: request.configuration,
|
|
181
|
+
scope,
|
|
182
|
+
queryFiles: request.queryFiles,
|
|
183
|
+
});
|
|
184
|
+
const warnings = [...build.warnings];
|
|
185
|
+
const loaded = pickIndex({ key, mode: request.mode }).index;
|
|
186
|
+
if (!loaded) warnings.push(`index ${key} was written but could not be reloaded; check ${cacheDir(fs)}`);
|
|
187
|
+
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
|
188
|
+
notes.push(`built ${key}: ${build.counts.symbols} symbols from ${build.counts.files} files in ${seconds}s (module attribution: ${build.source})`);
|
|
189
|
+
return { key, index: loaded, built: true, durationMs: Date.now() - started, warnings, notes, roots: roots.roots };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface SymbolLookupInput {
|
|
193
|
+
cwd: string;
|
|
194
|
+
query: string;
|
|
195
|
+
scope?: Scope;
|
|
196
|
+
limit?: number;
|
|
197
|
+
prefix?: boolean;
|
|
198
|
+
substring?: boolean;
|
|
199
|
+
module?: string;
|
|
200
|
+
includeEngine?: boolean;
|
|
201
|
+
/** Engine areas for the engine index (`"Runtime"`, `"Runtime/Core"`, …). Default `Runtime`, `Editor`, `Developer`. */
|
|
202
|
+
engineAreas?: string[];
|
|
203
|
+
/** Opt in to a minutes-long first engine build. Default false: the tool says how, it never stalls. */
|
|
204
|
+
allowEngineBuild?: boolean;
|
|
205
|
+
onProgress?: (message: string) => void;
|
|
206
|
+
fs?: FsAdapter;
|
|
207
|
+
deps?: ResolverDeps;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Definitions lookup. Project + plugin index by default; engine index only when asked. */
|
|
211
|
+
export async function symbolLookup(input: SymbolLookupInput) {
|
|
212
|
+
const fs = input.fs ?? realFs;
|
|
213
|
+
const notes: string[] = [];
|
|
214
|
+
const warnings: string[] = [];
|
|
215
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
216
|
+
const scope: Scope = input.scope ?? (input.includeEngine ? "all" : "project");
|
|
217
|
+
const limit = input.limit ?? 25;
|
|
218
|
+
const indexes: { key: string; scope: Scope; defs: number; generatedAt: number }[] = [];
|
|
219
|
+
const results: LookupResult[] = [];
|
|
220
|
+
|
|
221
|
+
const project = await ensureIndex({ workspace, mode: "definitions", scope: "project", onProgress: input.onProgress, fs });
|
|
222
|
+
if (project.index) {
|
|
223
|
+
indexes.push({ key: project.key, scope: "project", defs: project.index.meta.counts.symbols, generatedAt: Date.parse(project.index.meta.generatedAt) });
|
|
224
|
+
results.push(...lookupSymbol(project.index, input.query, { limit, prefix: input.prefix, substring: input.substring, module: input.module }));
|
|
225
|
+
}
|
|
226
|
+
notes.push(...project.notes);
|
|
227
|
+
warnings.push(...project.warnings);
|
|
228
|
+
|
|
229
|
+
const wantsEngine = scope === "engine" || scope === "all";
|
|
230
|
+
if (wantsEngine) {
|
|
231
|
+
if (!workspace.engineRoot) warnings.push("no engine resolved, so the engine index was skipped");
|
|
232
|
+
else {
|
|
233
|
+
const engine = await ensureIndex({ workspace, mode: "definitions", scope: "engine", engineAreas: input.engineAreas, allowEngineBuild: input.allowEngineBuild, onProgress: input.onProgress, fs });
|
|
234
|
+
if (engine.index) {
|
|
235
|
+
indexes.push({ key: engine.key, scope: "engine", defs: engine.index.meta.counts.symbols, generatedAt: Date.parse(engine.index.meta.generatedAt) });
|
|
236
|
+
results.push(...lookupSymbol(engine.index, input.query, { limit, prefix: input.prefix, substring: input.substring, module: input.module }));
|
|
237
|
+
}
|
|
238
|
+
notes.push(...engine.notes);
|
|
239
|
+
warnings.push(...engine.warnings);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
const engineIndexes = listIndexes().filter((state) => !!state.meta.engineRoot && !state.meta.projectRoot);
|
|
243
|
+
if (engineIndexes.length) notes.push(`engine index cached (${engineIndexes[0]?.key}); pass scope 'all' to search it`);
|
|
244
|
+
else notes.push("engine index not built — pass scope 'engine' or 'all' for engine symbols (first build takes minutes)");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Rank first, kind second: sorting by kind/name is what made a plugin forward declaration beat
|
|
248
|
+
// the engine's real `UCLASS` row and then get cut by the limit.
|
|
249
|
+
results.sort((a, b) => a.rank - b.rank || Number(b.marker.includes("forward")) - Number(a.marker.includes("forward")) || a.kind.localeCompare(b.kind) || a.symbol.localeCompare(b.symbol));
|
|
250
|
+
return { query: input.query, scope, total: results.length, results: results.slice(0, limit), indexes, notes, warnings, workspace: { projectPath: workspace.projectPath, engineRoot: workspace.engineRoot } };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface SymbolListInput {
|
|
254
|
+
cwd: string;
|
|
255
|
+
prefix: string;
|
|
256
|
+
scope?: Scope;
|
|
257
|
+
kind?: string;
|
|
258
|
+
module?: string;
|
|
259
|
+
limit?: number;
|
|
260
|
+
engineAreas?: string[];
|
|
261
|
+
allowEngineBuild?: boolean;
|
|
262
|
+
fs?: FsAdapter;
|
|
263
|
+
deps?: ResolverDeps;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Prefix listing — cheap, and the way to discover `FX`/`U`/`A` families. */
|
|
267
|
+
export async function symbolList(input: SymbolListInput) {
|
|
268
|
+
const fs = input.fs ?? realFs;
|
|
269
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
270
|
+
const scope: Scope = input.scope ?? "project";
|
|
271
|
+
const notes: string[] = [];
|
|
272
|
+
const handles: IndexHandle[] = [];
|
|
273
|
+
const project = await ensureIndex({ workspace, mode: "definitions", scope: "project", engineAreas: input.engineAreas, fs });
|
|
274
|
+
handles.push(project);
|
|
275
|
+
if (scope !== "project" && workspace.engineRoot) handles.push(await ensureIndex({ workspace, mode: "definitions", scope: "engine", engineAreas: input.engineAreas, allowEngineBuild: input.allowEngineBuild, fs }));
|
|
276
|
+
|
|
277
|
+
const symbols: ReturnType<typeof listSymbols> = [];
|
|
278
|
+
for (const handle of handles) {
|
|
279
|
+
if (!handle.index) continue;
|
|
280
|
+
symbols.push(...listSymbols(handle.index, input.prefix, { limit: input.limit ?? 50, kind: input.kind, module: input.module }));
|
|
281
|
+
notes.push(...handle.notes);
|
|
282
|
+
}
|
|
283
|
+
symbols.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
|
284
|
+
return { prefix: input.prefix, scope, total: symbols.length, symbols: symbols.slice(0, input.limit ?? 50), notes, indexes: handles.map((handle) => handle.key) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface FileContextInput {
|
|
288
|
+
cwd: string;
|
|
289
|
+
file: string;
|
|
290
|
+
mode?: Mode;
|
|
291
|
+
allowEngineBuild?: boolean;
|
|
292
|
+
includeDefines?: boolean;
|
|
293
|
+
limit?: number;
|
|
294
|
+
engineAreas?: string[];
|
|
295
|
+
refresh?: boolean;
|
|
296
|
+
onProgress?: (message: string) => void;
|
|
297
|
+
fs?: FsAdapter;
|
|
298
|
+
deps?: ResolverDeps;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** One file, everything needed to reason about compiling it. */
|
|
302
|
+
export async function fileContext(input: FileContextInput) {
|
|
303
|
+
const fs = input.fs ?? realFs;
|
|
304
|
+
const notes: string[] = [];
|
|
305
|
+
const warnings: string[] = [];
|
|
306
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
307
|
+
let mode: Mode = input.mode ?? "definitions";
|
|
308
|
+
let file = normalizePath(input.file);
|
|
309
|
+
if (!/^([a-z]:)?[\/]/i.test(file)) {
|
|
310
|
+
for (const base of [input.cwd, workspace.projectDir]) {
|
|
311
|
+
if (!base) continue;
|
|
312
|
+
const candidate = normalizePath(joinPath(base, file));
|
|
313
|
+
if (fs.exists(candidate)) {
|
|
314
|
+
notes.push(`resolved relative path to ${candidate}`);
|
|
315
|
+
file = candidate;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let queryFiles: CachePaths | undefined;
|
|
322
|
+
let choice: TargetChoice | undefined;
|
|
323
|
+
if (mode === "full") {
|
|
324
|
+
choice = pickTarget({ cwd: input.cwd, engineRoot: workspace.engineRoot, fs });
|
|
325
|
+
const ensured = await ensureQueryJson({ cacheRoot: cacheDir(fs), choice, engineRoot: workspace.engineRoot, fs, onStdout: input.onProgress });
|
|
326
|
+
queryFiles = ensured.source === "missing" ? undefined : ensured.paths;
|
|
327
|
+
notes.push(...ensured.notes);
|
|
328
|
+
warnings.push(...ensured.warnings);
|
|
329
|
+
if (!queryFiles) {
|
|
330
|
+
mode = "definitions";
|
|
331
|
+
warnings.push("falling back to definitions mode: UnrealBuildTool Query produced no JSON");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const scope: Scope = file.includes("/Engine/Source/") ? "engine" : "project";
|
|
336
|
+
const handle = await ensureIndex({ workspace, mode, scope, target: choice?.target, platform: choice?.platform, configuration: choice?.configuration, queryFiles, refresh: input.refresh, allowEngineBuild: input.allowEngineBuild, engineAreas: input.engineAreas, onProgress: input.onProgress, fs });
|
|
337
|
+
notes.push(...handle.notes);
|
|
338
|
+
warnings.push(...handle.warnings);
|
|
339
|
+
if (!handle.index) return { file, mode, notes, warnings, error: `no index available for ${scope} sources` };
|
|
340
|
+
|
|
341
|
+
let context = contextFor(handle.index, file, { limit: input.limit ?? 40, queryFiles: queryFiles ?? handle.index.meta.queryFiles });
|
|
342
|
+
if (!context) {
|
|
343
|
+
// A bare file name is the common case; resolve it against what the index actually holds.
|
|
344
|
+
const matches = findFileBySuffix(handle.index, baseName(file), 6);
|
|
345
|
+
if (matches.length === 1) {
|
|
346
|
+
file = matches[0]!;
|
|
347
|
+
notes.push(`resolved '${input.file}' to ${file} via the index`);
|
|
348
|
+
context = contextFor(handle.index, file, { limit: input.limit ?? 40, queryFiles: queryFiles ?? handle.index.meta.queryFiles });
|
|
349
|
+
} else if (matches.length > 1) {
|
|
350
|
+
return { file, mode, notes: [...notes, `multiple indexed files match '${baseName(file)}': ${matches.join(", ")}`], warnings, error: "ambiguous file name; pass a path with more directories" };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (!context) {
|
|
354
|
+
const other = mode === "full" ? "definitions" : "full";
|
|
355
|
+
const fallback = pickIndex({ mode: other as Mode, projectRoot: workspace.projectDir, engineRoot: workspace.engineRoot }).index;
|
|
356
|
+
if (fallback) {
|
|
357
|
+
const fromOther = contextFor(fallback, file, { limit: input.limit ?? 40, queryFiles: fallback.meta.queryFiles });
|
|
358
|
+
if (fromOther) {
|
|
359
|
+
notes.push(`file found in the ${other}-mode index ${fallback.key}, not ${mode}`);
|
|
360
|
+
return { file, mode: other, module: moduleFromBuildCs(file), context: fromOther, symbols: symbolsInFile(fallback, file, input.limit ?? 40), defines: input.includeDefines ? fromOther.defines : undefined, notes, warnings };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { file, mode, notes, warnings, error: `${file} is not in index ${handle.key} (it may be outside the scanned module roots, in ThirdParty, or a generated header in Intermediate/)` };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const symbols = symbolsInFile(handle.index, file, input.limit ?? 40);
|
|
367
|
+
const defines = input.includeDefines ? context.defines : undefined;
|
|
368
|
+
if (!context.includePaths?.length) notes.push("no include paths in this mode — build mode 'full' (unreal_index, mode: \"full\") for includePaths and the forced-included defines file");
|
|
369
|
+
notes.push(`module attribution: ${handle.index.meta.source === "ubt" ? "UnrealBuildTool Query dirToModule" : "*.Build.cs location (run a full-mode index for UBT's own answer)"}`);
|
|
370
|
+
return {
|
|
371
|
+
file,
|
|
372
|
+
mode,
|
|
373
|
+
module: context.module || moduleFromBuildCs(file),
|
|
374
|
+
indexKey: handle.key,
|
|
375
|
+
symbols,
|
|
376
|
+
compile: context,
|
|
377
|
+
defines,
|
|
378
|
+
notes,
|
|
379
|
+
warnings,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export interface BuildContextInput {
|
|
384
|
+
cwd: string;
|
|
385
|
+
target?: string;
|
|
386
|
+
platform?: string;
|
|
387
|
+
configuration?: string;
|
|
388
|
+
module?: string;
|
|
389
|
+
file?: string;
|
|
390
|
+
includeDefines?: boolean;
|
|
391
|
+
refresh?: boolean;
|
|
392
|
+
limit?: number;
|
|
393
|
+
engineAreas?: string[];
|
|
394
|
+
onProgress?: (message: string) => void;
|
|
395
|
+
fs?: FsAdapter;
|
|
396
|
+
deps?: ResolverDeps;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The compile environment UnrealBuildTool actually uses: target identity, defines, standard,
|
|
401
|
+
* SDK, include paths, and module dependencies — from Query JSON, cached in the pi cache.
|
|
402
|
+
*/
|
|
403
|
+
export async function buildContext(input: BuildContextInput) {
|
|
404
|
+
const fs = input.fs ?? realFs;
|
|
405
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
406
|
+
if (!workspace.engineRoot) return { error: "no engine resolved for this project; run unreal_resolve_engine", notes: workspace.notes };
|
|
407
|
+
|
|
408
|
+
const root = cacheDir(fs);
|
|
409
|
+
const choice = pickTarget({ cwd: input.cwd, target: input.target, platform: input.platform, configuration: input.configuration, engineRoot: workspace.engineRoot, fs });
|
|
410
|
+
const before = queryCacheState(root, choice, fs);
|
|
411
|
+
const notes: string[] = [`target ${choice.target} ${choice.platform} ${choice.configuration} (derived from ${choice.source})`];
|
|
412
|
+
const warnings: string[] = [...before.notes];
|
|
413
|
+
|
|
414
|
+
const ensured = await ensureQueryJson({ cacheRoot: root, choice, engineRoot: workspace.engineRoot, refresh: input.refresh, fs, onStdout: input.onProgress });
|
|
415
|
+
notes.push(...ensured.notes);
|
|
416
|
+
warnings.push(...ensured.warnings);
|
|
417
|
+
if (!ensured.paths) return { target: choice, notes, warnings, error: "UnrealBuildTool Query produced no JSON; inspect the notes for the spawn failure" };
|
|
418
|
+
|
|
419
|
+
const details = parseTargetDetails(readQueryJson(ensured.paths.targetDetails));
|
|
420
|
+
const moduleRefs: Map<string, UbtModuleRefs> = parseModuleReferences(readQueryJson(ensured.paths.moduleReferences));
|
|
421
|
+
|
|
422
|
+
const wanted = input.module ?? (input.file ? moduleFromBuildCs(normalizePath(input.file)) : undefined) ?? workspace.projectName;
|
|
423
|
+
const refs = wanted ? moduleRefs.get(wanted) : undefined;
|
|
424
|
+
const settings = refs ? details.settings.get(refs.moduleDirectory) : undefined;
|
|
425
|
+
if (!refs) notes.push(`no Query entry for module '${wanted ?? ""}'; pass 'module' — ${moduleRefs.size} modules in this target's graph`);
|
|
426
|
+
else if (!settings) notes.push(`module '${wanted}' has module references but no compile settings entry in TargetDetails`);
|
|
427
|
+
|
|
428
|
+
const headers = refs ? [...refs.public, ...refs.private, ...refs.publicInclude, ...refs.privateInclude] : [];
|
|
429
|
+
if (refs && !settings) notes.push("compile settings (includePaths, standard, forced includes) live in TargetDetails; this target's TargetDetails entry for the module was empty");
|
|
430
|
+
const handle = await ensureIndex({ workspace, mode: "definitions", scope: "project", engineAreas: input.engineAreas, fs });
|
|
431
|
+
const indexModules = handle.index ? listModules(handle.index, { limit: input.limit ?? 40 }) : [];
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
target: choice,
|
|
435
|
+
queryCache: { targetDetails: ensured.paths.targetDetails, moduleReferences: ensured.paths.moduleReferences, source: ensured.source, durationMs: ensured.durationsMs },
|
|
436
|
+
isTestTarget: details.isTestTarget,
|
|
437
|
+
launch: details.launch,
|
|
438
|
+
module: refs ? { name: wanted, directory: refs.moduleDirectory, dependencies: refs.public.length + refs.private.length, publicHeaders: refs.public.length, privateHeaders: refs.private.length, publicInclude: refs.publicInclude, privateInclude: refs.privateInclude, dynamicallyLoaded: refs.dynamicallyLoaded, circular: refs.circular, standard: settings?.standard, windowsSdkVersion: settings?.windowsSdkVersion, compilerPath: settings?.compilerPath, includePaths: settings?.includePaths.length ?? 0, forcedIncludes: settings?.forcedIncludes ?? [], definesFile: settings?.definesFile } : undefined,
|
|
439
|
+
headerCount: headers.length,
|
|
440
|
+
defines: input.includeDefines ? settings?.defines ?? [] : undefined,
|
|
441
|
+
modulesInTarget: [...moduleRefs.keys()].sort().slice(0, input.limit ?? 40),
|
|
442
|
+
projectModules: indexModules,
|
|
443
|
+
modulesTotal: moduleRefs.size,
|
|
444
|
+
targetModules: details.dirModules.length,
|
|
445
|
+
notes,
|
|
446
|
+
warnings,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** What is cached right now: symbol indexes and Query JSON, with ages and costs. */
|
|
451
|
+
export function indexStatus(cwd: string, fs: FsAdapter = realFs) {
|
|
452
|
+
const root = cacheDir(fs);
|
|
453
|
+
const indexes = listIndexes().map((state) => ({
|
|
454
|
+
key: state.key,
|
|
455
|
+
mode: state.meta.mode,
|
|
456
|
+
generatedAt: state.meta.generatedAt,
|
|
457
|
+
ageMs: Date.now() - Date.parse(state.meta.generatedAt),
|
|
458
|
+
defs: state.meta.counts.symbols,
|
|
459
|
+
files: state.meta.counts.files,
|
|
460
|
+
defsByKind: state.meta.defsByKind,
|
|
461
|
+
source: state.meta.source,
|
|
462
|
+
target: state.meta.target,
|
|
463
|
+
scope: state.scope,
|
|
464
|
+
roots: state.meta.roots.length,
|
|
465
|
+
sizeMb: Math.round(state.meta.sizeBytes / 1024 / 1024),
|
|
466
|
+
issues: state.issues,
|
|
467
|
+
}));
|
|
468
|
+
const choice = pickTarget({ cwd, fs });
|
|
469
|
+
const query = queryCacheState(root, choice, fs);
|
|
470
|
+
return { cacheDir: root, indexes, query: { target: choice, usable: query.usable, targetDetails: query.targetDetails, moduleReferences: query.moduleReferences, notes: query.notes } };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export { hostPlatform };
|
|
474
|
+
|
|
475
|
+
export interface IndexBuildInput {
|
|
476
|
+
cwd: string;
|
|
477
|
+
scope: Exclude<Scope, "all">;
|
|
478
|
+
mode: Mode;
|
|
479
|
+
engineAreas?: string[];
|
|
480
|
+
target?: string;
|
|
481
|
+
platform?: string;
|
|
482
|
+
configuration?: string;
|
|
483
|
+
refresh?: boolean;
|
|
484
|
+
onProgress?: (message: string) => void;
|
|
485
|
+
fs?: FsAdapter;
|
|
486
|
+
deps?: ResolverDeps;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Build (or rebuild) one symbol index. This is the only entry point that opts in to a minutes-long
|
|
491
|
+
* Engine/Source scan — lookups report the cost and hand back, they never stall an agent.
|
|
492
|
+
*/
|
|
493
|
+
export async function indexBuild(input: IndexBuildInput) {
|
|
494
|
+
const fs = input.fs ?? realFs;
|
|
495
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
496
|
+
const notes: string[] = [];
|
|
497
|
+
let queryFiles: CachePaths | undefined;
|
|
498
|
+
|
|
499
|
+
if (input.mode === "full") {
|
|
500
|
+
if (!workspace.engineRoot) return { key: "none", built: false, symbols: 0, files: 0, durationMs: 0, roots: 0, notes: [...workspace.notes, "no engine resolved, so UnrealBuildTool Query cannot run (run unreal_resolve_engine)"] };
|
|
501
|
+
const choice = pickTarget({ cwd: input.cwd, target: input.target, platform: input.platform, configuration: input.configuration, engineRoot: workspace.engineRoot, fs });
|
|
502
|
+
const ensured = await ensureQueryJson({ cacheRoot: cacheDir(fs), choice, engineRoot: workspace.engineRoot, refresh: input.refresh, fs, onStdout: input.onProgress });
|
|
503
|
+
notes.push(...ensured.notes);
|
|
504
|
+
queryFiles = ensured.paths;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const handle = await ensureIndex({
|
|
508
|
+
workspace,
|
|
509
|
+
mode: input.mode,
|
|
510
|
+
scope: input.scope,
|
|
511
|
+
engineAreas: input.engineAreas,
|
|
512
|
+
allowEngineBuild: true,
|
|
513
|
+
refresh: input.refresh,
|
|
514
|
+
target: input.target,
|
|
515
|
+
platform: input.platform,
|
|
516
|
+
configuration: input.configuration,
|
|
517
|
+
queryFiles,
|
|
518
|
+
onProgress: input.onProgress,
|
|
519
|
+
fs,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
key: handle.key,
|
|
524
|
+
built: handle.built,
|
|
525
|
+
mode: input.mode,
|
|
526
|
+
scope: input.scope,
|
|
527
|
+
engineAreas: input.engineAreas ?? DEFAULT_ENGINE_AREAS,
|
|
528
|
+
symbols: handle.index?.meta.counts.symbols ?? 0,
|
|
529
|
+
files: handle.index?.meta.counts.files ?? 0,
|
|
530
|
+
modules: handle.index?.meta.counts.modules ?? 0,
|
|
531
|
+
defsByKind: handle.index?.meta.defsByKind,
|
|
532
|
+
durationMs: handle.durationMs,
|
|
533
|
+
roots: handle.roots.length,
|
|
534
|
+
indexDir: handle.index?.dir,
|
|
535
|
+
notes: [...workspace.notes, ...handle.notes, ...notes],
|
|
536
|
+
warnings: handle.warnings,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/* ────────────────────────────────────────────────────────────── compile database */
|
|
541
|
+
|
|
542
|
+
/** Default clangd scope: the project, its enabled plugins, and (with 'engine') Engine/Source. */
|
|
543
|
+
export function compileDbScope(input: { projectDir?: string; pluginDirs?: string[]; engineRoot?: string; scope: "project" | "project+engine" }): string[] {
|
|
544
|
+
const include = [input.projectDir, ...(input.pluginDirs ?? [])].filter((dir): dir is string => Boolean(dir)).map(normalizePath);
|
|
545
|
+
if (input.scope === "project+engine" && input.engineRoot) include.push(normalizePath(joinPath(input.engineRoot, "Engine/Source")));
|
|
546
|
+
return include;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export interface CompileDbInput {
|
|
550
|
+
cwd: string;
|
|
551
|
+
/** 'project' = project + enabled plugin sources. 'project+engine' adds all of Engine/Source. */
|
|
552
|
+
scope?: "project" | "project+engine";
|
|
553
|
+
/** Explicit include prefixes; overrides 'scope'. */
|
|
554
|
+
include?: string[];
|
|
555
|
+
target?: string;
|
|
556
|
+
platform?: string;
|
|
557
|
+
configuration?: string;
|
|
558
|
+
refresh?: boolean;
|
|
559
|
+
/** Run `clangd --check` on this file after generating (acceptance gate). */
|
|
560
|
+
check?: string;
|
|
561
|
+
timeoutMs?: number;
|
|
562
|
+
onProgress?: (message: string) => void;
|
|
563
|
+
fs?: FsAdapter;
|
|
564
|
+
deps?: ResolverDeps;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Generate and normalize UBT's clangd database, then optionally prove it with `clangd --check`.
|
|
569
|
+
* Generation costs minutes (UBT regenerates the makefile and runs UHT), and the engine-wide scope
|
|
570
|
+
* is ~1 GB: this is the expensive path, and only the explicit tool call reaches it.
|
|
571
|
+
*/
|
|
572
|
+
export async function compileDbReport(input: CompileDbInput) {
|
|
573
|
+
const fs = input.fs ?? realFs;
|
|
574
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
575
|
+
const scope = input.scope ?? "project";
|
|
576
|
+
|
|
577
|
+
if (!workspace.engineRoot) {
|
|
578
|
+
return { ok: false, notes: [...workspace.notes, "no engine resolved, so UnrealBuildTool cannot run (see unreal_resolve_engine)"], warnings: [] as string[], entries: 0, seconds: 0, reused: false };
|
|
579
|
+
}
|
|
580
|
+
const target = input.target ?? pickDefaultTarget(discoverTargets({ projectDir: workspace.projectDir, engineRoot: workspace.engineRoot, fs }), workspace.projectName)?.name;
|
|
581
|
+
if (!target) {
|
|
582
|
+
return { ok: false, notes: [...workspace.notes, "no *.Target.cs found, so the compile database has no target to generate for"], warnings: [] as string[], entries: 0, seconds: 0, reused: false };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const include = input.include?.length ? input.include.map(normalizePath) : compileDbScope({ projectDir: workspace.projectDir, pluginDirs: workspace.pluginDirs, engineRoot: workspace.engineRoot, scope });
|
|
586
|
+
const ensured = await ensureCompileDb({
|
|
587
|
+
engineRoot: workspace.engineRoot,
|
|
588
|
+
projectPath: workspace.projectPath,
|
|
589
|
+
target,
|
|
590
|
+
platform: input.platform,
|
|
591
|
+
configuration: input.configuration,
|
|
592
|
+
include,
|
|
593
|
+
dropHeaders: true,
|
|
594
|
+
refresh: input.refresh,
|
|
595
|
+
timeoutMs: input.timeoutMs,
|
|
596
|
+
onProgress: input.onProgress,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const notes: string[] = [
|
|
600
|
+
`target ${target} (${hostPlatform()})`,
|
|
601
|
+
`scope: ${ensured.scope.join(", ")}`,
|
|
602
|
+
ensured.reused ? `reused ${ensured.path}` : `generated in ${ensured.seconds.toFixed(1)}s (raw ${ensured.rawPath ? baseName(ensured.rawPath) : "?"})`,
|
|
603
|
+
`normalized: ${ensured.stats.kept} entries (dropped ${ensured.stats.droppedPch} PCH units, ${ensured.stats.droppedHeaders} headers, ${ensured.stats.droppedSwitches} switches, expanded ${ensured.stats.expandedRsp} response files)`,
|
|
604
|
+
"point clangd at this directory with --compile-commands-dir, or copy the path into the project's .clangd; the database encodes this machine's paths, so keep it out of git",
|
|
605
|
+
];
|
|
606
|
+
let check: { ok: boolean; clangd?: string; summary: string[]; notes: string[] } | undefined;
|
|
607
|
+
if (input.check) {
|
|
608
|
+
const file = input.check.includes(":") || input.check.startsWith("/") || /^[a-z]:/i.test(input.check) ? input.check : normalizePath(joinPath(input.cwd, input.check));
|
|
609
|
+
const ran = await clangdCheck({ file, dbDir: ensured.path.replace(/\/compile_commands\.json$/, ""), timeoutMs: input.timeoutMs });
|
|
610
|
+
check = { ok: ran.ok, clangd: ran.clangd, summary: ran.summary, notes: ran.notes };
|
|
611
|
+
notes.push(...ran.notes.map((note) => `check: ${note}`));
|
|
612
|
+
}
|
|
613
|
+
return { ok: ensured.entries > 0, target, platform: input.platform ?? hostPlatform(), configuration: input.configuration ?? "Development", path: ensured.path, rawPath: ensured.rawPath, entries: ensured.entries, seconds: ensured.seconds, reused: ensured.reused, stats: ensured.stats, scope, check, notes: [...workspace.notes, ...notes], warnings: ensured.warnings };
|
|
614
|
+
}
|