@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.
@@ -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
- pluginDirs: engineScope ? [] : workspace.pluginDirs,
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: engineScope ? request.engineAreas : undefined,
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: roots.roots.map((root) => root.dir), scope, target: request.target, platform: request.platform, configuration: request.configuration });
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: roots.roots.map((root) => root.dir) }).index;
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 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");
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" ? "indexing Engine/Source (first engine index takes minutes, then it is cached)" : "indexing project and plugin sources";
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 (`"Runtime"`, `"Runtime/Core"`, …). Default `Runtime`, `Editor`, `Developer`. */
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.index) {
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.notes);
227
- warnings.push(...project.warnings);
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) 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)");
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
- return { query: input.query, scope, total: results.length, results: results.slice(0, limit), indexes, notes, warnings, workspace: { projectPath: workspace.projectPath, engineRoot: workspace.engineRoot } };
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
- const project = await ensureIndex({ workspace, mode: "definitions", scope: "project", engineAreas: input.engineAreas, fs });
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
- symbols.push(...listSymbols(handle.index, input.prefix, { limit: input.limit ?? 50, kind: input.kind, module: input.module }));
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 = file.includes("/Engine/Source/") ? "engine" : "project";
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
- 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/)` };
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
- return { cacheDir: root, indexes, query: { target: choice, usable: query.usable, targetDetails: query.targetDetails, moduleReferences: query.moduleReferences, notes: query.notes } };
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
- if (input.scope === "project+engine" && input.engineRoot) include.push(normalizePath(joinPath(input.engineRoot, "Engine/Source")));
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