@aeonsparx/pi-unreal 1.0.0 → 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/README.md +85 -88
- package/docs/internals.md +66 -5
- package/extensions/unreal/index.ts +18 -4
- package/extensions/unreal/lib/pi-tools.ts +70 -20
- package/extensions/unreal/lib/store.ts +633 -43
- package/extensions/unreal/lib/tools.ts +491 -31
- package/extensions/unreal/lib/ubt.ts +7 -0
- package/package.json +2 -1
- package/prompts/unreal-context.md +10 -0
- package/skills/ue-engine-resolution/SKILL.md +38 -5
|
@@ -13,25 +13,37 @@
|
|
|
13
13
|
|
|
14
14
|
import { parseUproject, type EngineVersion } from "./descriptors.ts";
|
|
15
15
|
import { cacheDir } from "./cache.ts";
|
|
16
|
-
import { buildPluginIndex, resolveProjectPlugins } from "./plugin-resolver.ts";
|
|
16
|
+
import { buildPluginIndex, resolveProjectPlugins, type PluginReferenceResult, type PluginSummary } from "./plugin-resolver.ts";
|
|
17
17
|
import { findProjectFile, resolveEngineForProject, type ResolverDeps } from "./engine-resolver.ts";
|
|
18
18
|
import { ensureQueryJson, pickTarget, queryCacheState, humanAge, type CachePaths, type TargetChoice } from "./query.ts";
|
|
19
19
|
import {
|
|
20
20
|
assembleRoots,
|
|
21
|
+
buildHeaderIndex,
|
|
21
22
|
buildIndex,
|
|
22
23
|
contextFor,
|
|
23
24
|
findFileBySuffix,
|
|
24
25
|
indexKeyFor,
|
|
25
26
|
listFiles,
|
|
27
|
+
headerKeyFor,
|
|
28
|
+
listHeaderIndexes,
|
|
26
29
|
listIndexes,
|
|
27
30
|
listModules,
|
|
28
31
|
listSymbols,
|
|
32
|
+
loadHeaderIndex,
|
|
33
|
+
lookupHeaderPaths,
|
|
29
34
|
lookupSymbol,
|
|
30
35
|
moduleFromBuildCs,
|
|
36
|
+
nameTokens,
|
|
37
|
+
pickHeaderIndex,
|
|
31
38
|
pickIndex,
|
|
32
39
|
readDefines,
|
|
40
|
+
rootKindsNote,
|
|
41
|
+
suggestFileNames,
|
|
42
|
+
suggestHeaderPaths,
|
|
33
43
|
symbolsInFile,
|
|
34
44
|
type ContextUnit,
|
|
45
|
+
type HeaderIndex,
|
|
46
|
+
type HeaderRow,
|
|
35
47
|
type IndexState,
|
|
36
48
|
type LookupResult,
|
|
37
49
|
type IndexScope,
|
|
@@ -53,12 +65,22 @@ export interface Workspace {
|
|
|
53
65
|
engineVersion?: EngineVersion;
|
|
54
66
|
/** Directories of plugins the `.uproject` enables, used as index roots. */
|
|
55
67
|
pluginDirs: string[];
|
|
68
|
+
/**
|
|
69
|
+
* Declared module names per plugin root dir (from each `.uplugin`). A module directory takes the
|
|
70
|
+
* name of its `*.Build.cs` file, which is how `Engine/Plugins/Animation/PoseSearch/Source/Runtime`
|
|
71
|
+
* is module `PoseSearch` and not the directory leaf `Runtime`.
|
|
72
|
+
*/
|
|
73
|
+
pluginModuleNames: Record<string, string[]>;
|
|
74
|
+
/** Every `.uproject` reference with its resolved descriptor — the source of 'is this plugin available'. */
|
|
75
|
+
pluginRefs: PluginReferenceResult[];
|
|
76
|
+
/** Every plugin descriptor the scan found, including plugins the `.uproject` does not reference. */
|
|
77
|
+
plugins: PluginSummary[];
|
|
56
78
|
notes: string[];
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
export async function resolveWorkspace(cwd: string, fs: FsAdapter = realFs, deps: ResolverDeps = {}): Promise<Workspace> {
|
|
60
82
|
const notes: string[] = [];
|
|
61
|
-
const workspace: Workspace = { cwd: normalizePath(cwd), pluginDirs: [], notes };
|
|
83
|
+
const workspace: Workspace = { cwd: normalizePath(cwd), pluginDirs: [], pluginModuleNames: {}, pluginRefs: [], plugins: [], notes };
|
|
62
84
|
|
|
63
85
|
const projectPath = findProjectFile(workspace.cwd, fs, 4);
|
|
64
86
|
if (!projectPath) {
|
|
@@ -88,7 +110,13 @@ export async function resolveWorkspace(cwd: string, fs: FsAdapter = realFs, deps
|
|
|
88
110
|
fs,
|
|
89
111
|
});
|
|
90
112
|
const refs = resolveProjectPlugins(descriptor, index, workspace.engineVersion);
|
|
113
|
+
workspace.pluginRefs = refs;
|
|
114
|
+
workspace.plugins = index.entries;
|
|
91
115
|
workspace.pluginDirs = refs.filter((ref) => ref.plugin).map((ref) => normalizePath(ref.plugin!.dir));
|
|
116
|
+
for (const entry of index.entries) {
|
|
117
|
+
const names = entry.modules.map((module) => module.moduleName).filter(Boolean);
|
|
118
|
+
if (names.length) workspace.pluginModuleNames[normalizePath(entry.dir)] = names;
|
|
119
|
+
}
|
|
92
120
|
const missing = refs.filter((ref) => ref.status === "missing").map((ref) => ref.name);
|
|
93
121
|
if (missing.length) notes.push(`plugin references with no descriptor: ${missing.join(", ")}`);
|
|
94
122
|
notes.push(`${workspace.pluginDirs.length} enabled plugin reference(s) resolved for index roots`);
|
|
@@ -134,37 +162,49 @@ export async function ensureIndex(request: IndexRequest): Promise<IndexHandle> {
|
|
|
134
162
|
const fs = request.fs ?? realFs;
|
|
135
163
|
const workspace = request.workspace;
|
|
136
164
|
const engineScope = request.scope === "engine";
|
|
165
|
+
const areas = engineScope ? (request.engineAreas?.length ? request.engineAreas : DEFAULT_ENGINE_AREAS) : undefined;
|
|
137
166
|
const roots = assembleRoots({
|
|
138
167
|
projectRoot: engineScope ? undefined : workspace.projectDir,
|
|
139
|
-
|
|
168
|
+
// Engine-scope indexes carry the project's enabled plugin roots too. `Engine/Source/<area>` alone
|
|
169
|
+
// answers questions about the engine's own types and nothing else: Mover, PoseSearch,
|
|
170
|
+
// PhysicsControl, BlendStack, SmartObjects and StateTree are all plugin-shipped, and the plugin
|
|
171
|
+
// roots are already resolved, so adding them costs no extra scan of things we already know about.
|
|
172
|
+
pluginDirs: workspace.pluginDirs,
|
|
173
|
+
pluginModuleNames: workspace.pluginModuleNames,
|
|
140
174
|
engineRoot: engineScope ? workspace.engineRoot : undefined,
|
|
141
175
|
includeEngine: engineScope,
|
|
142
|
-
engineAreas:
|
|
176
|
+
engineAreas: areas,
|
|
143
177
|
fs,
|
|
144
178
|
});
|
|
145
179
|
const scope: IndexScope = engineScope ? "Engine" : "Project";
|
|
180
|
+
const rootDirs = roots.roots.map((root) => root.dir);
|
|
181
|
+
const coverage = { projectRoot: workspace.projectDir, engineRoot: workspace.engineRoot };
|
|
146
182
|
const notes = [...roots.notes];
|
|
147
183
|
if (!roots.roots.length) {
|
|
148
184
|
return { key: "none", built: false, warnings: [`no C++ module roots for scope '${request.scope}'`], notes, roots: [] };
|
|
149
185
|
}
|
|
186
|
+
notes.push(`covers ${rootKindsNote(rootDirs, coverage)}`);
|
|
150
187
|
|
|
151
|
-
const key = indexKeyFor({ mode: request.mode, roots:
|
|
188
|
+
const key = indexKeyFor({ mode: request.mode, roots: rootDirs, scope, target: request.target, platform: request.platform, configuration: request.configuration });
|
|
152
189
|
if (!request.refresh) {
|
|
153
|
-
const existing = pickIndex({ key, mode: request.mode, scope, projectRoot: engineScope ? undefined : workspace.projectDir, engineRoot: engineScope ? workspace.engineRoot : undefined, roots:
|
|
190
|
+
const existing = pickIndex({ key, mode: request.mode, scope, projectRoot: engineScope ? undefined : workspace.projectDir, engineRoot: engineScope ? workspace.engineRoot : undefined, roots: rootDirs }).index;
|
|
154
191
|
if (existing) {
|
|
155
|
-
notes.push(`using cached index ${existing.key} (${existing.meta.counts.symbols} symbols, ${humanAge(Date.now() - Date.parse(existing.meta.generatedAt))} old)`);
|
|
192
|
+
notes.push(`using cached index ${existing.key} (${existing.meta.counts.symbols} symbols, ${existing.meta.counts.files} files, ${humanAge(Date.now() - Date.parse(existing.meta.generatedAt))} old)`);
|
|
193
|
+
const stale = indexCoverageNote(existing);
|
|
194
|
+
if (stale) notes.push(stale);
|
|
156
195
|
return { key, index: existing, built: false, warnings: [], notes, roots: roots.roots };
|
|
157
196
|
}
|
|
158
197
|
}
|
|
159
198
|
|
|
160
199
|
if (engineScope && !request.allowEngineBuild) {
|
|
161
|
-
const
|
|
162
|
-
notes.push(`no engine index for ${
|
|
163
|
-
notes.push(
|
|
200
|
+
const joined = (areas ?? DEFAULT_ENGINE_AREAS).join(", ");
|
|
201
|
+
notes.push(`no engine index for ${joined}. Building one scans every .h/.cpp in those areas plus ${workspace.pluginDirs.length} enabled plugin root(s) (tens of thousands of files): minutes the first time, cached afterwards.`);
|
|
202
|
+
notes.push(`call unreal_index with scope "engine" (engineAreas narrows the scan, e.g. ["Runtime"]; add ["Plugins"] for every engine plugin's sources, ["Plugins/Animation/PhysicsControl"] for one)`);
|
|
203
|
+
notes.push('unreal_index action "headers" answers "does this header exist in this engine version" in seconds without a content scan');
|
|
164
204
|
return { key, built: false, warnings: [], notes, roots: roots.roots };
|
|
165
205
|
}
|
|
166
206
|
|
|
167
|
-
const scopeNote = request.scope === "engine" ?
|
|
207
|
+
const scopeNote = request.scope === "engine" ? `indexing Engine/Source (${(areas ?? DEFAULT_ENGINE_AREAS).join(", ")}) and ${workspace.pluginDirs.length} enabled plugin tree(s); the first engine index takes minutes, then it is cached` : "indexing project and plugin sources";
|
|
168
208
|
notes.push(scopeNote);
|
|
169
209
|
request.onProgress?.(scopeNote);
|
|
170
210
|
const started = Date.now();
|
|
@@ -178,6 +218,7 @@ export async function ensureIndex(request: IndexRequest): Promise<IndexHandle> {
|
|
|
178
218
|
target: request.target,
|
|
179
219
|
platform: request.platform,
|
|
180
220
|
configuration: request.configuration,
|
|
221
|
+
areas,
|
|
181
222
|
scope,
|
|
182
223
|
queryFiles: request.queryFiles,
|
|
183
224
|
});
|
|
@@ -189,6 +230,213 @@ export async function ensureIndex(request: IndexRequest): Promise<IndexHandle> {
|
|
|
189
230
|
return { key, index: loaded, built: true, durationMs: Date.now() - started, warnings, notes, roots: roots.roots };
|
|
190
231
|
}
|
|
191
232
|
|
|
233
|
+
/** Coverage of an already-built index, read from its own meta — the line a warm cache must print. */
|
|
234
|
+
export function indexCoverageNote(state: IndexState): string {
|
|
235
|
+
const kinds = Object.entries(state.meta.rootKinds ?? {})
|
|
236
|
+
.map(([kind, count]) => `${count} ${kind} root(s)`)
|
|
237
|
+
.join(", ");
|
|
238
|
+
return `index ${state.key} covers ${kinds || `${state.meta.roots.length} root(s)`}${state.meta.areas?.length ? ` (engineAreas ${state.meta.areas.join(", ")})` : ""}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What an index actually scanned, in root-kind vocabulary — the only honest form of a 0-row answer. */
|
|
242
|
+
export interface Coverage {
|
|
243
|
+
key: string;
|
|
244
|
+
scope: Scope;
|
|
245
|
+
kinds: string;
|
|
246
|
+
areas: string[];
|
|
247
|
+
roots: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function coverageOf(handle: IndexHandle, workspace: Workspace, scope: Scope, areas?: string[]): Coverage {
|
|
251
|
+
return {
|
|
252
|
+
key: handle.key,
|
|
253
|
+
scope,
|
|
254
|
+
kinds: rootKindsNote(handle.roots.map((root) => root.dir), { projectRoot: workspace.projectDir, engineRoot: workspace.engineRoot }),
|
|
255
|
+
areas: areas ?? [],
|
|
256
|
+
roots: handle.roots.length,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Engine plugins present on disk that the `.uproject` does not reference. They are compiled by the
|
|
262
|
+
* target graph (their modules show up in `TargetModuleReferences`), and they are loaded when
|
|
263
|
+
* `EnabledByDefault` is true — but they are not index roots, so a symbol lookup cannot see them.
|
|
264
|
+
* This is the coverage hole behind every "the engine has no such type" answer.
|
|
265
|
+
*/
|
|
266
|
+
export function unreferencedEnginePlugins(workspace: Workspace): { enginePlugins: number; referenced: number; names: string[] } {
|
|
267
|
+
const referenced = new Set(workspace.pluginRefs.map((ref) => ref.name.toLowerCase()));
|
|
268
|
+
const seen = new Map<string, true>();
|
|
269
|
+
for (const plugin of workspace.plugins) {
|
|
270
|
+
if (plugin.source === "Engine" && !referenced.has(plugin.name.toLowerCase())) seen.set(plugin.name, true);
|
|
271
|
+
}
|
|
272
|
+
return { enginePlugins: workspace.plugins.filter((plugin) => plugin.source === "Engine").length, referenced: referenced.size, names: [...seen.keys()].sort() };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function coverageLines(coverage: Coverage[]): string[] {
|
|
276
|
+
return coverage.map((entry) => `scanned ${entry.scope} index ${entry.key}: ${entry.kinds}${entry.areas.length ? `; engineAreas ${entry.areas.join(", ")}` : ""}${entry.roots === 0 ? " (nothing scanned)" : ""}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Header index for a resolved engine, if one is already built. Never built here: lookups report, they do not scan. */
|
|
280
|
+
export function headerIndexForEngine(engineRoot?: string): HeaderIndex | undefined {
|
|
281
|
+
if (!engineRoot) return undefined;
|
|
282
|
+
const wanted = normalizePath(engineRoot).toLowerCase();
|
|
283
|
+
return listHeaderIndexes().find((header) => header.healthy && normalizePath(header.meta.engineRoot ?? "").toLowerCase() === wanted);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The `engineAreas` dial that scans exactly this plugin: its path under Engine/Plugins. */
|
|
287
|
+
function pluginAreaLabel(workspace: Workspace, plugin: PluginSummary): string {
|
|
288
|
+
const pluginsRoot = normalizePath(joinPath(workspace.engineRoot ?? "", "Engine/Plugins"));
|
|
289
|
+
const dir = normalizePath(plugin.dir);
|
|
290
|
+
if (dir.toLowerCase().startsWith(`${pluginsRoot.toLowerCase()}/`)) return `Plugins/${dir.slice(pluginsRoot.length + 1)}`;
|
|
291
|
+
return `Plugins/<Category>/${plugin.name}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Header index for this project's own sources and plugins, if one is already built. */
|
|
295
|
+
export function headerIndexForProject(workspace: Workspace): HeaderIndex | undefined {
|
|
296
|
+
const roots = projectHeaderRoots(workspace);
|
|
297
|
+
if (!roots.length) return undefined;
|
|
298
|
+
return pickHeaderIndex(roots);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isUnder(path: string, dir?: string): boolean {
|
|
302
|
+
if (!dir) return false;
|
|
303
|
+
const p = normalizePath(path).toLowerCase();
|
|
304
|
+
const d = normalizePath(dir).toLowerCase();
|
|
305
|
+
return p === d || p.startsWith(`${d}/`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Plugin descriptor that owns a file path, by root prefix (project plugins beat engine ones by path shape anyway). */
|
|
309
|
+
export function pluginForPath(workspace: Workspace, file: string): PluginSummary | undefined {
|
|
310
|
+
for (const plugin of workspace.plugins) {
|
|
311
|
+
if (isUnder(file, plugin.dir)) return plugin;
|
|
312
|
+
}
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Roots a path-only header index needs: engine source areas + every engine plugin's sources. */
|
|
317
|
+
export function engineHeaderRoots(workspace: Workspace, areas?: string[]): ScanRoot[] {
|
|
318
|
+
if (!workspace.engineRoot) return [];
|
|
319
|
+
const engineAreas = areas?.length ? areas : ["Runtime", "Plugins"];
|
|
320
|
+
return assembleRoots({ engineRoot: workspace.engineRoot, includeEngine: true, engineAreas, pluginModuleNames: workspace.pluginModuleNames, fs: realFs }).roots;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Roots a path-only header index needs for the project side: its `Source` tree and its plugins' sources. */
|
|
324
|
+
export function projectHeaderRoots(workspace: Workspace, fs: FsAdapter = realFs): string[] {
|
|
325
|
+
const roots = assembleRoots({ projectRoot: workspace.projectDir, pluginDirs: workspace.pluginDirs, pluginModuleNames: workspace.pluginModuleNames, fs }).roots;
|
|
326
|
+
return roots.map((root) => root.dir);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface HeaderAnswer {
|
|
330
|
+
name: string;
|
|
331
|
+
exists: boolean;
|
|
332
|
+
found: { file: string; module: string; kind: string; match: string; plugin?: string; pluginSource?: PluginSummary["source"]; enabledByDefault?: boolean; referencedInUproject?: boolean; inTargetGraphHint?: string }[];
|
|
333
|
+
candidates: { file: string; module: string }[];
|
|
334
|
+
indexKey?: string;
|
|
335
|
+
rootsScanned: string;
|
|
336
|
+
notes: string[];
|
|
337
|
+
warnings: string[];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* "Does this header exist in THIS engine version?" — a path question, answered from the header index.
|
|
342
|
+
*
|
|
343
|
+
* A definitions index cannot answer it: absence of a symbol row is a statement about the roots that
|
|
344
|
+
* were scanned, not about the engine. This answers with file paths only, and labels them as file
|
|
345
|
+
* names, never as types.
|
|
346
|
+
*/
|
|
347
|
+
export function headerLookup(input: { name: string; workspace: Workspace; header?: HeaderIndex; limit?: number }): HeaderAnswer {
|
|
348
|
+
const name = input.name.trim();
|
|
349
|
+
const notes: string[] = [];
|
|
350
|
+
const warnings: string[] = [];
|
|
351
|
+
const workspace = input.workspace;
|
|
352
|
+
const header = input.header;
|
|
353
|
+
if (!header) {
|
|
354
|
+
const engine = workspace.engineRoot ? "scope engine (Engine/Source areas + every Engine/Plugins source)" : "the project's own sources";
|
|
355
|
+
return {
|
|
356
|
+
name,
|
|
357
|
+
exists: false,
|
|
358
|
+
found: [],
|
|
359
|
+
candidates: [],
|
|
360
|
+
rootsScanned: "none",
|
|
361
|
+
notes: [...notes, `no header index for ${engine}; build one with unreal_index action "headers" (walks file paths only: seconds, no content scan, cached under the pi cache)`],
|
|
362
|
+
warnings,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const referenced = new Set(workspace.pluginRefs.map((ref) => ref.name.toLowerCase()));
|
|
367
|
+
const decorate = (row: HeaderRow) => {
|
|
368
|
+
const plugin = pluginForPath(workspace, row.file);
|
|
369
|
+
return {
|
|
370
|
+
file: row.file,
|
|
371
|
+
module: row.module,
|
|
372
|
+
kind: row.kind,
|
|
373
|
+
match: row.match,
|
|
374
|
+
plugin: plugin?.name,
|
|
375
|
+
pluginSource: plugin?.source,
|
|
376
|
+
enabledByDefault: plugin?.enabledByDefault,
|
|
377
|
+
referencedInUproject: plugin ? referenced.has(plugin.name.toLowerCase()) : undefined,
|
|
378
|
+
};
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const exact = lookupHeaderPaths(header, name, { limit: input.limit ?? 12 });
|
|
382
|
+
const candidates = exact.length ? [] : suggestHeaderPaths(header, name, { limit: 8 });
|
|
383
|
+
const rootsScanned = rootKindsNote(header.meta.roots, { projectRoot: header.meta.projectRoot, engineRoot: header.meta.engineRoot });
|
|
384
|
+
notes.push(`header index ${header.key} (${header.meta.files} header paths, ${rootsScanned})`);
|
|
385
|
+
if (!exact.length) {
|
|
386
|
+
notes.push(`'${name}' is not a file path in this engine index`);
|
|
387
|
+
if (candidates.length) notes.push(` closest real file names (files, not types):\n${candidates.map((row) => ` ${row.file} [${row.module}]`).join("\n")}`);
|
|
388
|
+
warnings.push(`'${name}' was not found; the answer is about file paths, not about C++ types`);
|
|
389
|
+
}
|
|
390
|
+
return { name, exists: exact.length > 0, found: exact.map(decorate), candidates: candidates.map((row) => ({ file: row.file, module: row.module })), indexKey: header.key, rootsScanned, notes, warnings };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Notes a 0-row answer must carry: what was scanned, what was not, and the exact next call. */
|
|
394
|
+
export function zeroResultNotes(input: { query: string; scope: Scope; workspace: Workspace; coverage: Coverage[]; engineAreas?: string[]; indexes: IndexState[]; header?: HeaderIndex }): string[] {
|
|
395
|
+
const notes: string[] = [`no definitions matched '${input.query}' in scope '${input.scope}'`];
|
|
396
|
+
notes.push(...coverageLines(input.coverage));
|
|
397
|
+
|
|
398
|
+
const wantsEngine = input.scope === "engine" || input.scope === "all";
|
|
399
|
+
if (input.workspace.engineRoot) {
|
|
400
|
+
const gap = unreferencedEnginePlugins(input.workspace);
|
|
401
|
+
if (wantsEngine) {
|
|
402
|
+
notes.push(`engine plugins on disk: ${gap.enginePlugins}, referenced by this .uproject: ${gap.referenced}; the engine index scans referenced plugins only`);
|
|
403
|
+
notes.push(`a plugin the project does not reference is not in any index root: pass engineAreas ["Plugins"] (every Engine/Plugins source, ~2.8x the Runtime scan) or ["Plugins/<Category>/<Plugin>"] for one plugin`);
|
|
404
|
+
const near = rankNamesByQuery(gap.names, input.query);
|
|
405
|
+
if (near.length) notes.push(`unreferenced engine plugins whose name matches the query: ${near.slice(0, 6).join(", ")}${gap.names.length > 6 ? ` (of ${gap.names.length} unreferenced engine plugins with sources here)` : ""}`);
|
|
406
|
+
}
|
|
407
|
+
notes.push(input.header ? `header index ${input.header.key} covers ${rootKindsNote(input.header.meta.roots, { engineRoot: input.header.meta.engineRoot })}` : `asking whether a header exists is a path question, not a symbol question: unreal_index action "headers" (path walk only, seconds) answers it from ${input.workspace.engineRoot}/Engine/Plugins and Engine/Source`);
|
|
408
|
+
}
|
|
409
|
+
if (!wantsEngine) {
|
|
410
|
+
notes.push("a project index contains the project and its enabled plugins only; engine sources are not scanned: pass scope \"engine\" (first build takes minutes) or scope \"all\"");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const suggestions = new Map<string, { file: string; module: string }>();
|
|
414
|
+
for (const index of input.indexes) {
|
|
415
|
+
for (const row of suggestFileNames(index, input.query, { limit: 4 })) {
|
|
416
|
+
if (!suggestions.has(row.file)) suggestions.set(row.file, { file: row.file, module: row.module });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (suggestions.size) {
|
|
420
|
+
notes.push(`closest indexed FILE names (file names, not types — they are not proof a type exists):\n${[...suggestions.values()].slice(0, 6).map((row) => ` ${row.file} [${row.module}]`).join("\n")}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
notes.push(`next: unreal_symbol query '${input.query.replace(/\.(h|hpp|hxx|inl)$/i, "")}' scope "all" prefix true, or unreal_index action "headers" name "${input.query}"`);
|
|
424
|
+
return notes;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Names ranked by how many of the query's name tokens they contain — the gap list must answer *this* question. */
|
|
428
|
+
function rankNamesByQuery(names: string[], query: string): string[] {
|
|
429
|
+
const tokens = nameTokens(baseName(query).replace(/\.(h|hpp|hxx|inl|cpp|cc|c)$/i, ""));
|
|
430
|
+
if (!tokens.length) return names;
|
|
431
|
+
const scored = names.map((name) => {
|
|
432
|
+
const mine = new Set(nameTokens(name));
|
|
433
|
+
let hits = 0;
|
|
434
|
+
for (const token of tokens) if (mine.has(token)) hits++;
|
|
435
|
+
return { name, hits };
|
|
436
|
+
});
|
|
437
|
+
return scored.filter((entry) => entry.hits > 0).sort((a, b) => b.hits - a.hits || a.name.localeCompare(b.name)).map((entry) => entry.name);
|
|
438
|
+
}
|
|
439
|
+
|
|
192
440
|
export interface SymbolLookupInput {
|
|
193
441
|
cwd: string;
|
|
194
442
|
query: string;
|
|
@@ -198,7 +446,7 @@ export interface SymbolLookupInput {
|
|
|
198
446
|
substring?: boolean;
|
|
199
447
|
module?: string;
|
|
200
448
|
includeEngine?: boolean;
|
|
201
|
-
/** Engine areas for the engine index
|
|
449
|
+
/** Engine areas for the engine index: `"Runtime"`, `"Runtime/Core"`, `"Editor"`, or the plugin dials `"Plugins"` / `"Plugins/Animation/PhysicsControl"`. Default `DEFAULT_ENGINE_AREAS` (`["Runtime"]`). */
|
|
202
450
|
engineAreas?: string[];
|
|
203
451
|
/** Opt in to a minutes-long first engine build. Default false: the tool says how, it never stalls. */
|
|
204
452
|
allowEngineBuild?: boolean;
|
|
@@ -215,16 +463,21 @@ export async function symbolLookup(input: SymbolLookupInput) {
|
|
|
215
463
|
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
216
464
|
const scope: Scope = input.scope ?? (input.includeEngine ? "all" : "project");
|
|
217
465
|
const limit = input.limit ?? 25;
|
|
218
|
-
const indexes: { key: string; scope: Scope; defs: number; generatedAt: number }[] = [];
|
|
466
|
+
const indexes: { key: string; scope: Scope; defs: number; files: number; generatedAt: number }[] = [];
|
|
219
467
|
const results: LookupResult[] = [];
|
|
468
|
+
const coverage: Coverage[] = [];
|
|
469
|
+
const searched: IndexState[] = [];
|
|
220
470
|
|
|
221
|
-
const project = await ensureIndex({ workspace, mode: "definitions", scope: "project", onProgress: input.onProgress, fs });
|
|
222
|
-
if (project
|
|
223
|
-
indexes.push({ key: project.key, scope: "project", defs: project.index.meta.counts.symbols, generatedAt: Date.parse(project.index.meta.generatedAt) });
|
|
471
|
+
const project = scope === "engine" ? undefined : await ensureIndex({ workspace, mode: "definitions", scope: "project", onProgress: input.onProgress, fs });
|
|
472
|
+
if (project?.index) {
|
|
473
|
+
indexes.push({ key: project.key, scope: "project", defs: project.index.meta.counts.symbols, files: project.index.meta.counts.files, generatedAt: Date.parse(project.index.meta.generatedAt) });
|
|
474
|
+
searched.push(project.index);
|
|
224
475
|
results.push(...lookupSymbol(project.index, input.query, { limit, prefix: input.prefix, substring: input.substring, module: input.module }));
|
|
225
476
|
}
|
|
226
|
-
notes.push(...project
|
|
227
|
-
warnings.push(...project
|
|
477
|
+
notes.push(...(project?.notes ?? []));
|
|
478
|
+
warnings.push(...(project?.warnings ?? []));
|
|
479
|
+
if (project) coverage.push(coverageOf(project, workspace, "project"));
|
|
480
|
+
if (scope === "engine") notes.push("scope 'engine' searches the engine index only: Engine/Source areas plus the plugin roots this .uproject enables");
|
|
228
481
|
|
|
229
482
|
const wantsEngine = scope === "engine" || scope === "all";
|
|
230
483
|
if (wantsEngine) {
|
|
@@ -232,22 +485,43 @@ export async function symbolLookup(input: SymbolLookupInput) {
|
|
|
232
485
|
else {
|
|
233
486
|
const engine = await ensureIndex({ workspace, mode: "definitions", scope: "engine", engineAreas: input.engineAreas, allowEngineBuild: input.allowEngineBuild, onProgress: input.onProgress, fs });
|
|
234
487
|
if (engine.index) {
|
|
235
|
-
indexes.push({ key: engine.key, scope: "engine", defs: engine.index.meta.counts.symbols, generatedAt: Date.parse(engine.index.meta.generatedAt) });
|
|
488
|
+
indexes.push({ key: engine.key, scope: "engine", defs: engine.index.meta.counts.symbols, files: engine.index.meta.counts.files, generatedAt: Date.parse(engine.index.meta.generatedAt) });
|
|
489
|
+
searched.push(engine.index);
|
|
236
490
|
results.push(...lookupSymbol(engine.index, input.query, { limit, prefix: input.prefix, substring: input.substring, module: input.module }));
|
|
237
491
|
}
|
|
238
492
|
notes.push(...engine.notes);
|
|
239
493
|
warnings.push(...engine.warnings);
|
|
494
|
+
coverage.push(coverageOf(engine, workspace, "engine", input.engineAreas));
|
|
240
495
|
}
|
|
241
496
|
} else {
|
|
242
497
|
const engineIndexes = listIndexes().filter((state) => !!state.meta.engineRoot && !state.meta.projectRoot);
|
|
243
|
-
if (engineIndexes.length)
|
|
244
|
-
|
|
498
|
+
if (engineIndexes.length) {
|
|
499
|
+
const best = engineIndexes[0];
|
|
500
|
+
notes.push(`engine index cached (${best?.key}; ${best ? rootKindsNote(best.meta.roots, { engineRoot: best.meta.engineRoot }) : "roots unknown"}${best?.meta.areas?.length ? `; areas ${best.meta.areas.join(", ")}` : ""}) — pass scope 'all' to search it`);
|
|
501
|
+
} else {
|
|
502
|
+
notes.push("engine index not built — pass scope 'engine' or 'all' for engine symbols (first build takes minutes; engineAreas [\"Plugins\"] adds every engine plugin's sources)");
|
|
503
|
+
}
|
|
245
504
|
}
|
|
246
505
|
|
|
506
|
+
// A plugin root appears in both the project index and the engine index, so `all` sees the same
|
|
507
|
+
// declaration twice. Same symbol/file/line is one fact: keep the first, drop the echo.
|
|
508
|
+
const unique = new Map<string, LookupResult>();
|
|
509
|
+
for (const row of results) {
|
|
510
|
+
const id = `${row.symbol}\u0000${row.file}\u0000${row.line}`;
|
|
511
|
+
const kept = unique.get(id);
|
|
512
|
+
if (!kept || row.rank < kept.rank) unique.set(id, row);
|
|
513
|
+
}
|
|
514
|
+
results.length = 0;
|
|
515
|
+
results.push(...unique.values());
|
|
516
|
+
|
|
247
517
|
// Rank first, kind second: sorting by kind/name is what made a plugin forward declaration beat
|
|
248
518
|
// the engine's real `UCLASS` row and then get cut by the limit.
|
|
249
519
|
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
|
-
|
|
520
|
+
const total = results.length;
|
|
521
|
+
if (!total) {
|
|
522
|
+
notes.push(...zeroResultNotes({ query: input.query, scope, workspace, coverage, engineAreas: input.engineAreas, indexes: searched, header: wantsEngine ? headerIndexForEngine(workspace.engineRoot) : headerIndexForProject(workspace) }));
|
|
523
|
+
}
|
|
524
|
+
return { query: input.query, scope, total, results: results.slice(0, limit), indexes, coverage, notes, warnings, workspace: { projectPath: workspace.projectPath, engineRoot: workspace.engineRoot } };
|
|
251
525
|
}
|
|
252
526
|
|
|
253
527
|
export interface SymbolListInput {
|
|
@@ -269,21 +543,39 @@ export async function symbolList(input: SymbolListInput) {
|
|
|
269
543
|
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
270
544
|
const scope: Scope = input.scope ?? "project";
|
|
271
545
|
const notes: string[] = [];
|
|
546
|
+
const project = scope === "engine" ? undefined : await ensureIndex({ workspace, mode: "definitions", scope: "project", engineAreas: input.engineAreas, fs });
|
|
272
547
|
const handles: IndexHandle[] = [];
|
|
273
|
-
|
|
274
|
-
handles.push(project);
|
|
548
|
+
if (project) handles.push(project);
|
|
275
549
|
if (scope !== "project" && workspace.engineRoot) handles.push(await ensureIndex({ workspace, mode: "definitions", scope: "engine", engineAreas: input.engineAreas, allowEngineBuild: input.allowEngineBuild, fs }));
|
|
276
550
|
|
|
277
551
|
const symbols: ReturnType<typeof listSymbols> = [];
|
|
552
|
+
const seen = new Set<string>();
|
|
278
553
|
for (const handle of handles) {
|
|
279
554
|
if (!handle.index) continue;
|
|
280
|
-
|
|
555
|
+
for (const row of listSymbols(handle.index, input.prefix, { limit: input.limit ?? 50, kind: input.kind, module: input.module })) {
|
|
556
|
+
const id = `${row.symbol}\u0000${row.file}\u0000${row.line}`;
|
|
557
|
+
if (seen.has(id)) continue;
|
|
558
|
+
seen.add(id);
|
|
559
|
+
symbols.push(row);
|
|
560
|
+
}
|
|
281
561
|
notes.push(...handle.notes);
|
|
282
562
|
}
|
|
283
563
|
symbols.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
|
284
564
|
return { prefix: input.prefix, scope, total: symbols.length, symbols: symbols.slice(0, input.limit ?? 50), notes, indexes: handles.map((handle) => handle.key) };
|
|
285
565
|
}
|
|
286
566
|
|
|
567
|
+
/** Scope by path shape. Engine plugins are engine sources; project plugins are project sources. */
|
|
568
|
+
export function scopeForPath(file: string, workspace: Workspace): Exclude<Scope, "all"> {
|
|
569
|
+
const p = normalizePath(file).toLowerCase();
|
|
570
|
+
if (workspace.engineRoot) {
|
|
571
|
+
const er = normalizePath(workspace.engineRoot).toLowerCase();
|
|
572
|
+
if (p.startsWith(`${er}/engine/plugins/`) || p.startsWith(`${er}/engine/source/`)) return "engine";
|
|
573
|
+
}
|
|
574
|
+
// An engine-shaped path with no resolved engine root: answer it as engine, say why it failed.
|
|
575
|
+
if (/\/engine\/plugins\/[^/]+\/source\//.test(p) || /\/engine\/source\//.test(p)) return "engine";
|
|
576
|
+
return "project";
|
|
577
|
+
}
|
|
578
|
+
|
|
287
579
|
export interface FileContextInput {
|
|
288
580
|
cwd: string;
|
|
289
581
|
file: string;
|
|
@@ -332,7 +624,7 @@ export async function fileContext(input: FileContextInput) {
|
|
|
332
624
|
}
|
|
333
625
|
}
|
|
334
626
|
|
|
335
|
-
const scope: Scope
|
|
627
|
+
const scope: Exclude<Scope, "all"> = scopeForPath(file, workspace);
|
|
336
628
|
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
629
|
notes.push(...handle.notes);
|
|
338
630
|
warnings.push(...handle.warnings);
|
|
@@ -360,7 +652,25 @@ export async function fileContext(input: FileContextInput) {
|
|
|
360
652
|
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
653
|
}
|
|
362
654
|
}
|
|
363
|
-
|
|
655
|
+
// The path question is answerable even when the symbol question is not: a header can exist in a
|
|
656
|
+
// plugin this project never indexed, and 'not in index' must not read as 'does not exist'.
|
|
657
|
+
const header = scope === "engine" ? headerIndexForEngine(workspace.engineRoot) : headerIndexForProject(workspace);
|
|
658
|
+
if (header) {
|
|
659
|
+
const rows = lookupHeaderPaths(header, baseName(file), { limit: 6 });
|
|
660
|
+
const hit = rows.find((row) => row.file.toLowerCase() === file.toLowerCase());
|
|
661
|
+
if (hit) {
|
|
662
|
+
const plugin = pluginForPath(workspace, hit.file);
|
|
663
|
+
const referenced = plugin ? workspace.pluginRefs.some((ref) => ref.name.toLowerCase() === plugin.name.toLowerCase()) : false;
|
|
664
|
+
notes.push(`file exists in header index ${header.key} (${hit.kind}, module ${hit.module}) but is not in any symbol-index root`);
|
|
665
|
+
if (plugin) {
|
|
666
|
+
notes.push(`owning plugin ${plugin.name} [${plugin.source}] · EnabledByDefault ${plugin.enabledByDefault} · .uproject reference ${referenced ? "yes" : "no"}`);
|
|
667
|
+
notes.push(`compile C++ from it: unreal_index action build scope engine engineAreas ["${pluginAreaLabel(workspace, plugin)}"] (one plugin's sources), then re-run this call`);
|
|
668
|
+
}
|
|
669
|
+
return { file, mode, module: hit.module, indexKey: header.key, headerOnly: true, headerKind: hit.kind, pluginName: plugin?.name, pluginSource: plugin?.source, enabledByDefault: plugin?.enabledByDefault, referencedInUproject: referenced, notes, warnings };
|
|
670
|
+
}
|
|
671
|
+
notes.push(`header index ${header.key} (${header.meta.files} header paths) has no file named '${baseName(file)}'`);
|
|
672
|
+
}
|
|
673
|
+
return { file, mode, notes, warnings, error: `${file} is not in index ${handle.key} (${indexCoverageNote(handle.index)}). It may be outside the scanned module roots, in ThirdParty, or a generated header in Intermediate/. unreal_index action "headers" answers whether the path exists in this engine version.` };
|
|
364
674
|
}
|
|
365
675
|
|
|
366
676
|
const symbols = symbolsInFile(handle.index, file, input.limit ?? 40);
|
|
@@ -380,6 +690,42 @@ export async function fileContext(input: FileContextInput) {
|
|
|
380
690
|
};
|
|
381
691
|
}
|
|
382
692
|
|
|
693
|
+
export interface ModuleAvailability {
|
|
694
|
+
module: string;
|
|
695
|
+
target: string;
|
|
696
|
+
inTargetGraph: boolean;
|
|
697
|
+
directory?: string;
|
|
698
|
+
type?: string;
|
|
699
|
+
plugin?: string;
|
|
700
|
+
pluginSource?: PluginSummary["source"];
|
|
701
|
+
enabledByDefault?: boolean;
|
|
702
|
+
referencedInUproject: boolean;
|
|
703
|
+
notes: string[];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Is this module usable here? Two independent facts the `.uproject` alone cannot answer:
|
|
708
|
+
* `TargetModuleReferences` says whether the target's graph compiles the module at all, and the plugin
|
|
709
|
+
* descriptor says who owns it and whether it loads without a reference (`EnabledByDefault`).
|
|
710
|
+
*/
|
|
711
|
+
export function moduleAvailability(input: { workspace: Workspace; moduleRefs: Map<string, UbtModuleRefs>; moduleName: string; target: string }): ModuleAvailability {
|
|
712
|
+
const { workspace, moduleRefs, moduleName, target } = input;
|
|
713
|
+
const refs = moduleRefs.get(moduleName);
|
|
714
|
+
const plugin = (refs?.moduleDirectory ? workspace.plugins.find((entry) => isUnder(refs.moduleDirectory, entry.dir)) : undefined) ?? workspace.plugins.find((entry) => entry.modules.some((module) => module.moduleName === moduleName));
|
|
715
|
+
const declared = plugin?.modules.find((module) => module.moduleName === moduleName);
|
|
716
|
+
const referenced = plugin ? workspace.pluginRefs.some((ref) => ref.name.toLowerCase() === plugin.name.toLowerCase()) : false;
|
|
717
|
+
const type = refs?.type || declared?.type;
|
|
718
|
+
const notes: string[] = [` in ${target} module graph: ${refs ? `yes (${refs.moduleDirectory})` : "no — TargetModuleReferences has no entry for it"}`];
|
|
719
|
+
if (plugin) {
|
|
720
|
+
notes.push(` owning plugin ${plugin.name} [${plugin.source}] · declared module Type ${type ?? "unset"}`);
|
|
721
|
+
notes.push(` .uproject reference: ${plugin.source === "Engine" ? (referenced ? "referenced" : "NOT referenced") : "not an engine-plugin reference"}`);
|
|
722
|
+
if (!referenced) notes.push(plugin.enabledByDefault ? " EnabledByDefault true: it loads with no .uproject reference" : " EnabledByDefault false: runtime use needs a .uproject reference with Enabled true");
|
|
723
|
+
} else {
|
|
724
|
+
notes.push(" no .uplugin declares this module (engine source module, project module, or a name the graph does not contain)");
|
|
725
|
+
}
|
|
726
|
+
return { module: moduleName, target, inTargetGraph: Boolean(refs), directory: refs?.moduleDirectory, type, plugin: plugin?.name, pluginSource: plugin?.source, enabledByDefault: plugin?.enabledByDefault, referencedInUproject: referenced, notes };
|
|
727
|
+
}
|
|
728
|
+
|
|
383
729
|
export interface BuildContextInput {
|
|
384
730
|
cwd: string;
|
|
385
731
|
target?: string;
|
|
@@ -425,6 +771,9 @@ export async function buildContext(input: BuildContextInput) {
|
|
|
425
771
|
if (!refs) notes.push(`no Query entry for module '${wanted ?? ""}'; pass 'module' — ${moduleRefs.size} modules in this target's graph`);
|
|
426
772
|
else if (!settings) notes.push(`module '${wanted}' has module references but no compile settings entry in TargetDetails`);
|
|
427
773
|
|
|
774
|
+
const availability = wanted ? moduleAvailability({ workspace, moduleRefs, moduleName: wanted, target: `${choice.target} ${choice.platform} ${choice.configuration}` }) : undefined;
|
|
775
|
+
if (availability) notes.push(...availability.notes);
|
|
776
|
+
|
|
428
777
|
const headers = refs ? [...refs.public, ...refs.private, ...refs.publicInclude, ...refs.privateInclude] : [];
|
|
429
778
|
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
779
|
const handle = await ensureIndex({ workspace, mode: "definitions", scope: "project", engineAreas: input.engineAreas, fs });
|
|
@@ -436,6 +785,7 @@ export async function buildContext(input: BuildContextInput) {
|
|
|
436
785
|
isTestTarget: details.isTestTarget,
|
|
437
786
|
launch: details.launch,
|
|
438
787
|
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,
|
|
788
|
+
availability,
|
|
439
789
|
headerCount: headers.length,
|
|
440
790
|
defines: input.includeDefines ? settings?.defines ?? [] : undefined,
|
|
441
791
|
modulesInTarget: [...moduleRefs.keys()].sort().slice(0, input.limit ?? 40),
|
|
@@ -447,7 +797,7 @@ export async function buildContext(input: BuildContextInput) {
|
|
|
447
797
|
};
|
|
448
798
|
}
|
|
449
799
|
|
|
450
|
-
/** What is cached right now: symbol indexes and Query JSON, with ages and costs. */
|
|
800
|
+
/** What is cached right now: symbol indexes, header indexes and Query JSON, with ages, coverage and costs. */
|
|
451
801
|
export function indexStatus(cwd: string, fs: FsAdapter = realFs) {
|
|
452
802
|
const root = cacheDir(fs);
|
|
453
803
|
const indexes = listIndexes().map((state) => ({
|
|
@@ -462,12 +812,31 @@ export function indexStatus(cwd: string, fs: FsAdapter = realFs) {
|
|
|
462
812
|
target: state.meta.target,
|
|
463
813
|
scope: state.scope,
|
|
464
814
|
roots: state.meta.roots.length,
|
|
815
|
+
rootKinds: state.meta.rootKinds,
|
|
816
|
+
coverage: rootKindsNote(state.meta.roots, { projectRoot: state.meta.projectRoot, engineRoot: state.meta.engineRoot }),
|
|
817
|
+
areas: state.meta.areas,
|
|
465
818
|
sizeMb: Math.round(state.meta.sizeBytes / 1024 / 1024),
|
|
466
819
|
issues: state.issues,
|
|
467
820
|
}));
|
|
821
|
+
const headers = listHeaderIndexes().map((header) => ({
|
|
822
|
+
key: header.key,
|
|
823
|
+
generatedAt: header.meta.generatedAt,
|
|
824
|
+
ageMs: header.ageMs,
|
|
825
|
+
files: header.meta.files,
|
|
826
|
+
roots: header.meta.roots.length,
|
|
827
|
+
rootKinds: header.meta.rootKinds,
|
|
828
|
+
coverage: rootKindsNote(header.meta.roots, { projectRoot: header.meta.projectRoot, engineRoot: header.meta.engineRoot }),
|
|
829
|
+
areas: header.meta.areas,
|
|
830
|
+
sizeMb: Math.round(header.meta.sizeBytes / 1024 / 1024),
|
|
831
|
+
issues: header.issues,
|
|
832
|
+
}));
|
|
468
833
|
const choice = pickTarget({ cwd, fs });
|
|
469
834
|
const query = queryCacheState(root, choice, fs);
|
|
470
|
-
|
|
835
|
+
const notes = [
|
|
836
|
+
"engine scope covers Engine/Source areas + the project's enabled plugin roots; engine plugins the .uproject does not reference need engineAreas [\"Plugins\"] (~2.8x the Runtime scan) or [\"Plugins/<Category>/<Plugin>\"] (one plugin)",
|
|
837
|
+
"an index key encodes its root set, so a key that is not listed here is not what the last lookup searched — check the rootKinds line before trusting a 0",
|
|
838
|
+
];
|
|
839
|
+
return { cacheDir: root, indexes, headers, notes, query: { target: choice, usable: query.usable, targetDetails: query.targetDetails, moduleReferences: query.moduleReferences, notes: query.notes } };
|
|
471
840
|
}
|
|
472
841
|
|
|
473
842
|
export { hostPlatform };
|
|
@@ -537,12 +906,103 @@ export async function indexBuild(input: IndexBuildInput) {
|
|
|
537
906
|
};
|
|
538
907
|
}
|
|
539
908
|
|
|
909
|
+
/* ─────────────────────────────────────────────── header (path-only) index */
|
|
910
|
+
|
|
911
|
+
export interface HeaderReportInput {
|
|
912
|
+
cwd: string;
|
|
913
|
+
/** Header name or path to answer, e.g. `"PhysicsControlComponent.h"` or `"Mover/Public/MoverTypes.h"`. */
|
|
914
|
+
name?: string;
|
|
915
|
+
/** Which side to index. Default `all` (engine plugin sources + engine sources + project sources). */
|
|
916
|
+
scope?: Exclude<Scope, "all"> | "all";
|
|
917
|
+
/** Build when absent. Default false: a lookup reports the cost, it never scans on its own. */
|
|
918
|
+
build?: boolean;
|
|
919
|
+
/** Engine areas to walk. Default `["Runtime", "Plugins"]` — the whole plugin tree is the point of this index. */
|
|
920
|
+
engineAreas?: string[];
|
|
921
|
+
refresh?: boolean;
|
|
922
|
+
limit?: number;
|
|
923
|
+
fs?: FsAdapter;
|
|
924
|
+
deps?: ResolverDeps;
|
|
925
|
+
onProgress?: (message: string) => void;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The 'does this header exist in THIS engine version' answer.
|
|
930
|
+
*
|
|
931
|
+
* A definitions index cannot give it: absence of a symbol row is a statement about the roots that were
|
|
932
|
+
* scanned. This is a path walk (no content read), cached under the pi cache, and it names the plugin
|
|
933
|
+
* that owns each hit plus the two availability facts a `.uproject` reader needs — `EnabledByDefault`
|
|
934
|
+
* and whether the project references the plugin at all.
|
|
935
|
+
*/
|
|
936
|
+
export async function headerReport(input: HeaderReportInput) {
|
|
937
|
+
const fs = input.fs ?? realFs;
|
|
938
|
+
const workspace = await resolveWorkspace(input.cwd, fs, input.deps);
|
|
939
|
+
const scope = input.scope ?? "all";
|
|
940
|
+
const notes: string[] = [...workspace.notes];
|
|
941
|
+
const warnings: string[] = [];
|
|
942
|
+
|
|
943
|
+
const roots: ScanRoot[] = [];
|
|
944
|
+
if (scope === "all" || scope === "engine") roots.push(...engineHeaderRoots(workspace, input.engineAreas));
|
|
945
|
+
if (scope === "all" || scope === "project") roots.push(...assembleRoots({ projectRoot: workspace.projectDir, pluginDirs: workspace.pluginDirs, pluginModuleNames: workspace.pluginModuleNames, fs }).roots);
|
|
946
|
+
const dirs = roots.map((root) => root.dir);
|
|
947
|
+
if (!dirs.length) {
|
|
948
|
+
notes.push("no C++ roots to walk: no project `Source` tree and no resolved engine");
|
|
949
|
+
return { built: false, exists: false, name: input.name, roots: 0, files: 0, durationMs: 0, rootKinds: "none", found: [], candidates: [], notes, warnings };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const kinds = rootKindsNote(dirs, { projectRoot: workspace.projectDir, engineRoot: workspace.engineRoot });
|
|
953
|
+
let header = pickHeaderIndex(dirs);
|
|
954
|
+
if (header && !input.refresh) {
|
|
955
|
+
notes.push(`using cached header index ${header.key} (${header.meta.files} header paths, ${header.ageMs ? humanAge(header.ageMs) : "fresh"} old)`);
|
|
956
|
+
} else if (!header && !input.build) {
|
|
957
|
+
notes.push(`no header index for ${kinds}. Build it with unreal_index action "headers" — a path walk only (no content read): seconds, cached afterwards, ~52,000 plugin headers on a UE 5.8 install.`);
|
|
958
|
+
notes.push(` engineAreas default here is ["Runtime", "Plugins"]: every Engine/Plugins source tree, which is where Mover, PoseSearch, PhysicsControl, BlendStack, SmartObjects and StateTree live`);
|
|
959
|
+
return { built: false, exists: false, name: input.name, roots: dirs.length, files: 0, durationMs: 0, rootKinds: kinds, found: [], candidates: [], notes, warnings };
|
|
960
|
+
} else {
|
|
961
|
+
const key = headerKeyFor({ roots: dirs, label: scope === "project" ? workspace.projectName : workspace.engineRoot ? baseName(workspace.engineRoot) : "index" });
|
|
962
|
+
const areaNote = `walking header paths for ${kinds}`;
|
|
963
|
+
notes.push(areaNote);
|
|
964
|
+
input.onProgress?.(areaNote);
|
|
965
|
+
const built = await buildHeaderIndex({ key, roots, engineRoot: workspace.engineRoot, projectRoot: workspace.projectDir, areas: input.engineAreas ?? (scope === "project" ? undefined : ["Runtime", "Plugins"]) });
|
|
966
|
+
const loaded = loadHeaderIndex(built.key);
|
|
967
|
+
if (!loaded) warnings.push(`header index ${built.key} was written but could not be reloaded`);
|
|
968
|
+
header = loaded;
|
|
969
|
+
notes.push(`built ${built.key}: ${built.files} header paths in ${(built.durationMs / 1000).toFixed(1)}s`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (!input.name || !header) {
|
|
973
|
+
return { built: true, name: input.name, key: header?.key, files: header?.meta.files ?? 0, roots: dirs.length, durationMs: 0, rootKinds: kinds, exists: header ? undefined : false, found: [], candidates: [], notes, warnings };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const answer = headerLookup({ name: input.name, workspace, header, limit: input.limit });
|
|
977
|
+
notes.push(...answer.notes);
|
|
978
|
+
warnings.push(...answer.warnings);
|
|
979
|
+
return {
|
|
980
|
+
built: true,
|
|
981
|
+
key: header.key,
|
|
982
|
+
files: header.meta.files,
|
|
983
|
+
roots: dirs.length,
|
|
984
|
+
rootKinds: kinds,
|
|
985
|
+
name: input.name,
|
|
986
|
+
exists: answer.exists,
|
|
987
|
+
found: answer.found,
|
|
988
|
+
candidates: answer.candidates,
|
|
989
|
+
notes,
|
|
990
|
+
warnings,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
540
994
|
/* ────────────────────────────────────────────────────────────── compile database */
|
|
541
995
|
|
|
542
|
-
/** Default clangd scope: the project, its enabled plugins, and (with 'engine') Engine/Source. */
|
|
996
|
+
/** Default clangd scope: the project, its enabled plugins, and (with 'engine') all of Engine/Source AND Engine/Plugins sources. */
|
|
543
997
|
export function compileDbScope(input: { projectDir?: string; pluginDirs?: string[]; engineRoot?: string; scope: "project" | "project+engine" }): string[] {
|
|
544
998
|
const include = [input.projectDir, ...(input.pluginDirs ?? [])].filter((dir): dir is string => Boolean(dir)).map(normalizePath);
|
|
545
|
-
|
|
999
|
+
// Engine plugins ship most of the types a gameplay project overrides (Mover, PoseSearch, PhysicsControl,
|
|
1000
|
+
// SmartObjects, …). Listing only Engine/Source leaves their .cpp bodies out of the database, which is
|
|
1001
|
+
// the same coverage hole the engine symbol scope had.
|
|
1002
|
+
if (input.scope === "project+engine" && input.engineRoot) {
|
|
1003
|
+
include.push(normalizePath(joinPath(input.engineRoot, "Engine/Source")));
|
|
1004
|
+
include.push(normalizePath(joinPath(input.engineRoot, "Engine/Plugins")));
|
|
1005
|
+
}
|
|
546
1006
|
return include;
|
|
547
1007
|
}
|
|
548
1008
|
|