@aeonsparx/pi-unreal 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,900 @@
1
+ /**
2
+ * The on-disk symbol index and the queries that run against it.
3
+ *
4
+ * Layout (under `cacheDir()/indexes/<key>/`, i.e. `~/.pi/agent/unreal-cache` by default — not the
5
+ * project tree, which is 1.5 GB, and not git):
6
+ *
7
+ * meta.json build metadata: roots, mode, source (ubt|heuristic), counts, warnings, engine and
8
+ * target identity, paths to the UnrealBuildTool Query JSON it was built from
9
+ * defs.tsv symbol \t kind \t file \t line \t module \t scope \t marker — sorted by
10
+ * lowercased symbol, so exact lookup is a binary search over byte ranges
11
+ * includes.tsv file \t module \t includes \t symbols — per-file `#include` set and declared
12
+ * symbols, the base of the compile unit in `unreal_context` (full indexes)
13
+ * files.tsv file \t module \t kind \t symbols — file inventory (module filter, file listing)
14
+ *
15
+ * Text, not SQLite: this box has no build toolchain for native modules, bun is an optional runtime,
16
+ * and a TSV is inspectable with `rg`. Two facts make that cheap enough at engine scale:
17
+ * - 696,237 definitions (Lp + 5.8 engine) produce a 69 MB defs.tsv in 17 s;
18
+ * - a sorted TSV answers exact and prefix lookup by binary search over byte ranges, which is what
19
+ * agents actually ask for (clangd is the tool for position-sensitive questions, not this one).
20
+ *
21
+ * Every query takes an explicit `IndexState` and the tools echo `index: {key, mode, source, roots}`
22
+ * on every result, because a workspace can hold several indexes (definitions for the engine, full
23
+ * for the project, one per target) and the answer depends on which one was read.
24
+ */
25
+
26
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
27
+ import { cacheDir } from "./cache.ts";
28
+ import { normalizePath, joinPath, shortHash, baseName, realFs, type FsAdapter } from "./util.ts";
29
+ import { collectSourceFiles, scanText, type IncludeRef, type ScanRoot, type SymbolDef } from "./scan.ts";
30
+ import { parseModuleReferences, parseTargetDetails, readQueryJson, type UbtModuleRefs, type UbtModuleSettings } from "./ubt.ts";
31
+
32
+ export const INDEX_VERSION = 5;
33
+
34
+ /**
35
+ * Default engine scan. `Runtime` is where gameplay types live (AActor, UAnimInstance,
36
+ * UGameModeBase) and is what project questions actually touch: measured 6,212 files / 126,350
37
+ * definitions in 34 s on a UE 5.8 install. `Editor` and `Developer` roughly triple that, so they
38
+ * are opt-in through `engineAreas`.
39
+ */
40
+ export const DEFAULT_ENGINE_AREAS = ["Runtime"];
41
+
42
+ export type IndexScope = "Engine" | "Project" | "Plugin" | "";
43
+
44
+ export interface IndexMeta {
45
+ version: number;
46
+ key: string;
47
+ mode: "definitions" | "full";
48
+ /** `ubt` = module attribution from Query data; `heuristic` = nearest `*.Build.cs`. */
49
+ source: "ubt" | "heuristic";
50
+ /** Provenance of the indexed roots: `Engine` = engine sources only, `Project` = project + plugins. */
51
+ scope: IndexScope;
52
+ generatedAt: string;
53
+ roots: string[];
54
+ engineRoot?: string;
55
+ /** Index that supplied the engine half of a project index. */
56
+ engineIndexKey?: string;
57
+ projectRoot?: string;
58
+ projectName?: string;
59
+ target?: string;
60
+ platform?: string;
61
+ configuration?: string;
62
+ counts: { files: number; symbols: number; includes: number; modules: number };
63
+ defsByKind: Record<string, number>;
64
+ warnings: string[];
65
+ sizeBytes: number;
66
+ defsPath: string;
67
+ filesPath: string;
68
+ includesPath?: string;
69
+ queryFiles?: { targetDetails: string; moduleReferences: string };
70
+ queryDurationsMs?: { targetDetails: number; moduleReferences: number };
71
+ }
72
+
73
+ export interface IndexState {
74
+ key: string;
75
+ dir: string;
76
+ meta: IndexMeta;
77
+ fs: FsAdapter;
78
+ ageMs: number;
79
+ healthy: boolean;
80
+ issues: string[];
81
+ /** `meta.scope`, derived from the roots when an older meta.json has none. */
82
+ scope: IndexScope;
83
+ }
84
+
85
+ export interface IndexBuild {
86
+ key: string;
87
+ dir: string;
88
+ mode: IndexMeta["mode"];
89
+ source: IndexMeta["source"];
90
+ scope: IndexMeta["scope"];
91
+ roots: string[];
92
+ counts: IndexMeta["counts"];
93
+ defsByKind: Record<string, number>;
94
+ warnings: string[];
95
+ durationMs: number;
96
+ meta: IndexMeta;
97
+ }
98
+
99
+ export type DefRow = { symbol: string; kind: string; file: string; line: number; module: string; scope: string; marker: string };
100
+
101
+ /**
102
+ * Provenance of a set of scan roots. Plugin module directories inside an installed engine tree count as
103
+ * project-side (`.../Engine/Plugins/...`), so a project + plugins index classifies as `Project` and an
104
+ * Engine/Source index classifies as `Engine`. Mixed roots give "" — the index is not scoped.
105
+ */
106
+ export function deriveScope(roots: string[], options: { projectRoot?: string; engineRoot?: string } = {}): IndexScope {
107
+ const project = options.projectRoot ? normalizePath(options.projectRoot).toLowerCase() : undefined;
108
+ const engine = options.engineRoot ? normalizePath(options.engineRoot).toLowerCase() : undefined;
109
+ let engineSide = 0;
110
+ let projectSide = 0;
111
+ let pluginSide = 0;
112
+ for (const root of roots) {
113
+ const r = normalizePath(root).toLowerCase();
114
+ if (project && (r === project || r.startsWith(`${project}/`))) {
115
+ projectSide++;
116
+ continue;
117
+ }
118
+ if (engine && r.startsWith(`${engine}/`)) {
119
+ if (r.includes("/engine/plugins/")) pluginSide++;
120
+ else engineSide++;
121
+ continue;
122
+ }
123
+ if (r.includes("/engine/plugins/")) pluginSide++;
124
+ }
125
+ if (engineSide && (projectSide || pluginSide)) return "";
126
+ if (engineSide) return "Engine";
127
+ if (projectSide) return "Project";
128
+ if (pluginSide) return "Plugin";
129
+ return "";
130
+ }
131
+
132
+ export interface LookupResult extends DefRow {
133
+ /** 0 = exact, 1 = prefix, 2 = qualified-member fallback. */
134
+ rank: number;
135
+ }
136
+
137
+ const TSV_HEADER = ["symbol", "kind", "file", "line", "module", "scope", "marker"];
138
+
139
+ /* ──────────────────────────────────────────────────────────────────────── build */
140
+
141
+ const esc = (value: string) => (value.includes("\t") || value.includes("\n") ? JSON.stringify(value) : value);
142
+
143
+ /**
144
+ * Build an index from scan roots.
145
+ *
146
+ * `resolveModule` is the module attribution: with Query data it is UBT's `dirToModule` (authoritative,
147
+ * deepest-prefix match); without it, the nearest `*.Build.cs` above the file. The result records which
148
+ * one ran, so a heuristic index never pretends to be UBT-accurate.
149
+ */
150
+ /** Rows are sorted by the probe key (see `probe`): that is the order `lowerBound` binary-searches. */
151
+ function byProbe(a: DefRow, b: DefRow): number {
152
+ const x = probe(a.symbol);
153
+ const y = probe(b.symbol);
154
+ if (x !== y) return x < y ? -1 : 1;
155
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
156
+ return a.line - b.line;
157
+ }
158
+
159
+ export async function buildIndex(input: {
160
+ key: string;
161
+ mode: "definitions" | "full";
162
+ roots: ScanRoot[];
163
+ engineRoot?: string;
164
+ engineIndexKey?: string;
165
+ projectRoot?: string;
166
+ projectName?: string;
167
+ target?: string;
168
+ platform?: string;
169
+ configuration?: string;
170
+ queryFiles?: { targetDetails: string; moduleReferences: string };
171
+ /** Where the ScanRoot provenance comes from; `deriveScope(roots)` when omitted. */
172
+ scope?: IndexScope;
173
+ concurrency?: number;
174
+ }): Promise<IndexBuild> {
175
+ const started = Date.now();
176
+ const dir = indexDir(input.key);
177
+ const warnings: string[] = [];
178
+ const query = input.queryFiles && existsSync(input.queryFiles.targetDetails) ? parseTargetDetails(readQueryJson(input.queryFiles.targetDetails)) : undefined;
179
+ const refs = input.queryFiles && existsSync(input.queryFiles.moduleReferences) ? parseModuleReferences(readQueryJson(input.queryFiles.moduleReferences)) : undefined;
180
+ if (input.queryFiles && !query) warnings.push(`Query JSON unreadable at ${input.queryFiles.targetDetails}; module attribution falls back to *.Build.cs locations`);
181
+
182
+ const dirToModule = query ? [...query.dirModules].sort((a, b) => b.prefix.length - a.prefix.length) : [];
183
+ const moduleOf = (file: string, root: ScanRoot): string => {
184
+ const hit = dirToModule.find((entry) => file.startsWith(entry.prefix.endsWith("/") ? entry.prefix : `${entry.prefix}/`));
185
+ if (hit) return hit.module;
186
+ if (root.module) return root.module;
187
+ return moduleFromBuildCs(file);
188
+ };
189
+
190
+ const files = await collectSourceFiles(input.roots, { concurrency: input.concurrency });
191
+ const rows: DefRow[] = [];
192
+ const fileRows: string[] = [];
193
+ const includeRows: string[] = [];
194
+ const byFileIncludes = new Map<string, string[]>();
195
+ const modules = new Set<string>();
196
+ const defsByKind: Record<string, number> = {};
197
+
198
+ for (const entry of files) {
199
+ let text: string;
200
+ try {
201
+ text = readFileSync(entry.abs, "utf8");
202
+ } catch {
203
+ continue; // locked by a running editor build: skip the file, do not fail the index
204
+ }
205
+ const module = moduleOf(entry.abs, entry.root);
206
+ const { defs, includes } = scanText(text, entry.abs, module, entry.root.scope);
207
+ modules.add(module);
208
+ for (const def of defs) {
209
+ rows.push({ symbol: def.symbol, kind: def.kind, file: def.file, line: def.line, module: def.module, scope: def.scope, marker: def.marker });
210
+ defsByKind[def.kind] = (defsByKind[def.kind] ?? 0) + 1;
211
+ }
212
+ fileRows.push(`${esc(entry.abs)}\t${esc(module)}\t${fileKindLabel(entry.abs)}\t${defs.length}`);
213
+ if (input.mode === "full") includeRows.push(`${esc(entry.abs)}\t${esc(module)}\t${includes.map((inc) => (inc.angled ? `<${inc.target}>` : `"${inc.target}"`)).join(",")}\t${defs.map((def) => def.symbol).join(",")}`);
214
+ }
215
+
216
+ rows.sort(byProbe);
217
+
218
+ const defsTsv = `${TSV_HEADER.join("\t")}\n${rows.map((r) => [esc(r.symbol), r.kind, esc(r.file), r.line, esc(r.module), esc(r.scope), esc(r.marker)].join("\t")).join("\n")}\n`;
219
+ writeTextAtomic(joinPath(dir, "defs.tsv"), defsTsv);
220
+ writeTextAtomic(joinPath(dir, "files.tsv"), `file\tmodule\tkind\tsymbols\n${fileRows.join("\n")}\n`);
221
+ if (input.mode === "full") writeTextAtomic(joinPath(dir, "includes.tsv"), `file\tmodule\tincludes\tsymbols\n${includeRows.join("\n")}\n`);
222
+
223
+ const meta: IndexMeta = {
224
+ version: INDEX_VERSION,
225
+ key: input.key,
226
+ mode: input.mode,
227
+ source: query && refs ? "ubt" : "heuristic",
228
+ scope: input.scope ?? deriveScope(input.roots.map((root) => root.dir), { projectRoot: input.projectRoot, engineRoot: input.engineRoot }),
229
+ generatedAt: new Date().toISOString(),
230
+ roots: input.roots.map((root) => normalizePath(root.dir)),
231
+ engineRoot: input.engineRoot ? normalizePath(input.engineRoot) : undefined,
232
+ engineIndexKey: input.engineIndexKey,
233
+ projectRoot: input.projectRoot ? normalizePath(input.projectRoot) : undefined,
234
+ projectName: input.projectName,
235
+ target: input.target,
236
+ platform: input.platform,
237
+ configuration: input.configuration,
238
+ counts: { files: fileRows.length, symbols: rows.length, includes: includeRows.length, modules: modules.size },
239
+ defsByKind,
240
+ warnings,
241
+ sizeBytes: Buffer.byteLength(defsTsv, "utf8"),
242
+ defsPath: normalizePath(joinPath(dir, "defs.tsv")),
243
+ filesPath: normalizePath(joinPath(dir, "files.tsv")),
244
+ includesPath: input.mode === "full" ? normalizePath(joinPath(dir, "includes.tsv")) : undefined,
245
+ queryFiles: input.queryFiles,
246
+ };
247
+ writeJsonAtomic(joinPath(dir, "meta.json"), meta);
248
+
249
+ return { key: input.key, dir, mode: input.mode, source: meta.source, scope: meta.scope, roots: meta.roots, counts: meta.counts, defsByKind, warnings, durationMs: Date.now() - started, meta };
250
+ }
251
+
252
+ /**
253
+ * Re-scan only the files a caller names (an agent that just edited three files), merging rows into
254
+ * the existing TSV. Kept as a whole-file rewrite: 69 MB rewritten in ~1 s is cheaper than a
255
+ * patchable index format, and the result is always consistent.
256
+ */
257
+ export async function patchIndex(index: IndexState, files: string[], resolveModule: (file: string) => { module: string; scope: IndexScope }): Promise<{ updated: number; added: number; removed: number }> {
258
+ const rows = readDefRows(index);
259
+ const byFile = new Map<string, DefRow[]>();
260
+ for (const row of rows) {
261
+ const list = byFile.get(row.file) ?? [];
262
+ list.push(row);
263
+ byFile.set(row.file, list);
264
+ }
265
+ let updated = 0;
266
+ let added = 0;
267
+ let removed = 0;
268
+ for (const file of files.map(normalizePath)) {
269
+ const existing = byFile.get(file);
270
+ if (!existing) added++;
271
+ else removed += existing.length;
272
+ let text: string;
273
+ try {
274
+ text = readFileSync(file, "utf8");
275
+ } catch {
276
+ continue;
277
+ }
278
+ const { module, scope } = resolveModule(file);
279
+ const { defs } = scanText(text, file, module, scope);
280
+ byFile.set(file, defs.map((def) => ({ symbol: def.symbol, kind: def.kind, file: def.file, line: def.line, module: def.module, scope: def.scope, marker: def.marker })));
281
+ updated++;
282
+ }
283
+ const merged = [...byFile.values()].flat();
284
+ merged.sort(byProbe);
285
+ writeTextAtomic(index.meta.defsPath, `${TSV_HEADER.join("\t")}\n${merged.map((r) => [esc(r.symbol), r.kind, esc(r.file), r.line, esc(r.module), esc(r.scope), esc(r.marker)].join("\t")).join("\n")}\n`);
286
+ lineIndexCache.delete(index.meta.defsPath);
287
+ return { updated, added, removed };
288
+ }
289
+
290
+ /* ──────────────────────────────────────────────────────────────────────── lookup */
291
+
292
+ /** Byte offset of every line start, cached per file: one read, then every lookup is a seek. */
293
+ const lineIndexCache = new Map<string, { offsets: number[]; size: number }>();
294
+
295
+ function lineOffsets(path: string): { offsets: number[]; size: number } {
296
+ const size = statSync(path).size;
297
+ const cached = lineIndexCache.get(path);
298
+ if (cached && cached.size === size) return cached;
299
+ const fd = openSync(path, "r");
300
+ try {
301
+ const buffer = Buffer.alloc(size);
302
+ const read = readSync(fd, buffer, 0, size, 0);
303
+ const offsets: number[] = [0];
304
+ for (let i = 0; i < read; i++) if (buffer[i] === 0x0a) offsets.push(i + 1);
305
+ const entry = { offsets, size };
306
+ lineIndexCache.set(path, entry);
307
+ return entry;
308
+ } finally {
309
+ closeSync(fd);
310
+ }
311
+ }
312
+
313
+ function readRange(path: string, start: number, end: number): string {
314
+ const fd = openSync(path, "r");
315
+ try {
316
+ const length = Math.max(0, Math.min(end, statSync(path).size) - start);
317
+ const buffer = Buffer.alloc(length);
318
+ const read = readSync(fd, buffer, 0, length, start);
319
+ return buffer.subarray(0, read).toString("utf8");
320
+ } finally {
321
+ closeSync(fd);
322
+ }
323
+ }
324
+
325
+ /** Parse TSV rows (header skipped). `numericColumn` is parsed as a number. */
326
+ export function readTsv(text: string, numericColumn = -1): Record<string, string | number>[] {
327
+ const out: Record<string, string | number>[] = [];
328
+ const lines = text.split("\n");
329
+ if (lines.length < 2) return out;
330
+ const header = (lines[0] ?? "").split("\t");
331
+ for (let i = 1; i < lines.length; i++) {
332
+ const line = lines[i];
333
+ if (!line) continue;
334
+ const cells = line.split("\t");
335
+ const row: Record<string, string | number> = {};
336
+ for (let c = 0; c < header.length; c++) {
337
+ const key = header[c];
338
+ if (!key) continue;
339
+ const raw = cells[c] ?? "";
340
+ row[key] = c === numericColumn ? Number(raw) || 0 : raw.startsWith('"') ? (JSON.parse(raw) as string) : raw;
341
+ }
342
+ out.push(row);
343
+ }
344
+ return out;
345
+ }
346
+
347
+ function readDefRows(index: IndexState): DefRow[] {
348
+ return readTsv(readFileSync(index.meta.defsPath, "utf8"), 3) as unknown as DefRow[];
349
+ }
350
+
351
+ function readFileRows(index: IndexState): { file: string; module: string; kind: string; symbols: number }[] {
352
+ return readTsv(readFileSync(index.meta.filesPath, "utf8"), 3) as unknown as { file: string; module: string; kind: string; symbols: number }[];
353
+ }
354
+
355
+ /** Binary search the sorted defs for the first row whose symbol is >= needle (lowercased, colon-free). */
356
+ function lowerBound(path: string, needle: string): number {
357
+ const { offsets } = lineOffsets(path);
358
+ let lo = 1; // skip header
359
+ let hi = Math.max(1, offsets.length - 1);
360
+ while (lo < hi) {
361
+ const mid = (lo + hi) >> 1;
362
+ const start = offsets[mid] ?? 0;
363
+ const next = offsets[mid + 1] ?? start + 200;
364
+ const slice = readRange(path, start, Math.min(next, start + 200));
365
+ const field = (slice.split("\t")[0] ?? "").toLowerCase();
366
+ if (probe(field) < needle) lo = mid + 1;
367
+ else hi = mid;
368
+ }
369
+ return lo;
370
+ }
371
+
372
+ /** Rows starting at a line index, stopping at the first row whose symbol no longer matches. */
373
+ function rowsFrom(path: string, fromIndex: number, limit: number, matches: (row: DefRow) => boolean, scanCap?: number): DefRow[] {
374
+ const { offsets } = lineOffsets(path);
375
+ const size = statSync(path).size;
376
+ const out: DefRow[] = [];
377
+ // `limit` caps the answer; `scanCap` caps the work. Popular symbols have hundreds of rows (AActor:
378
+ // 283 in the Runtime index) and a cap tied to the answer size means rows past the cap are never
379
+ // read — the canonical Engine/.../GameFramework/Actor.h declaration lost to a CoreUObject one on
380
+ // path collation order alone.
381
+ const cap = scanCap ?? limit * 4;
382
+ for (let i = fromIndex, scanned = 0; i < offsets.length - 1 && out.length < limit && scanned < cap; i++, scanned++) {
383
+ const cells = readRange(path, offsets[i] ?? 0, offsets[i + 1] ?? size).split("\t");
384
+ if (cells.length < 7) continue;
385
+ const row: DefRow = { symbol: unesc(cells[0] ?? ""), kind: cells[1] ?? "", file: unesc(cells[2] ?? ""), line: Number(cells[3]), module: unesc(cells[4] ?? ""), scope: unesc(cells[5] ?? ""), marker: unesc(cells[6] ?? "") };
386
+ if (!matches(row)) break;
387
+ out.push(row);
388
+ }
389
+ return out;
390
+ }
391
+
392
+ const unesc = (value: string) => (value.startsWith('"') ? (JSON.parse(value) as string) : value);
393
+ /** Lookup key: case- and colon-insensitive, so `A:Tick`, `A::Tick` and `a tick` all probe the same. */
394
+ const probe = (raw: string) => raw.toLowerCase().replace(/[:\s]/g, "");
395
+
396
+ const defPath = (index: IndexState) => index.meta.defsPath;
397
+
398
+ /** `Class` / `Class:Method` / `Class::Method` → the class part and the method part. */
399
+ export function splitQualified(symbol: string): { owner: string; member: string } {
400
+ const cleaned = symbol.trim().replace(/::/g, ":");
401
+ const index = cleaned.indexOf(":");
402
+ if (index < 0) return { owner: cleaned, member: "" };
403
+ return { owner: cleaned.slice(0, index), member: cleaned.slice(index + 1).replace(/:/g, "") };
404
+ }
405
+
406
+ /**
407
+ * Exact symbol lookup, no fuzzy guessing: qualified and case-insensitive, and a bare method name
408
+ * resolves through its owner (`Tick` + `AActor`). Prefix matching is opt-in so a typo never turns
409
+ * into a plausible-looking path.
410
+ */
411
+ export function lookupSymbol(index: IndexState, query: string, options: { limit?: number; prefix?: boolean; substring?: boolean; module?: string } = {}): LookupResult[] {
412
+ const path = defPath(index);
413
+ const limit = Math.max(1, options.limit ?? 50);
414
+ const { owner, member } = splitQualified(query);
415
+ const needle = probe(member ? `${owner}:${member}` : owner);
416
+ const out: LookupResult[] = [];
417
+ const accept = (row: DefRow, rank: number) => {
418
+ if (options.module && row.module !== options.module) return;
419
+ // A forward declaration is a pointer, not a definition: keep it visible (it is how agents find
420
+ // the type at all) but never ahead of the real declaration.
421
+ const forward = /^forward/.test(row.marker) ? 1 : 0;
422
+ out.push({ ...row, rank: rank + forward });
423
+ };
424
+
425
+ // Pass 1: exact symbol match. Rows are sorted by the probe key, so an exact family is one
426
+ // contiguous run: walk the whole run, rank forward declarations last, keep the best `limit`.
427
+ const exact = rowsFrom(path, lowerBound(path, needle), Number.MAX_SAFE_INTEGER, (row) => probe(row.symbol) === needle, Number.MAX_SAFE_INTEGER);
428
+ for (const row of [...exact].sort((a, b) => (/^forward/.test(a.marker) ? 1 : 0) - (/^forward/.test(b.marker) ? 1 : 0)).slice(0, limit)) accept(row, 0);
429
+
430
+ // Pass 2: qualified-member fallback — a method row is `Owner:Method`, so `Tick` under `AActor`
431
+ // matches when the row's owner is the requested one.
432
+ if (!out.length && member) {
433
+ const memberNeedle = probe(member);
434
+ const ownerProbe = probe(owner);
435
+ for (const row of rowsFrom(path, lowerBound(path, memberNeedle), limit, (row) => probe(row.symbol) === memberNeedle, 20000)) {
436
+ const rowOwner = splitQualified(row.symbol).owner;
437
+ if (ownerProbe && probe(rowOwner) === ownerProbe) accept(row, 2);
438
+ }
439
+ }
440
+
441
+ // Pass 3: prefix, only when asked for.
442
+ if (!out.length && options.prefix) {
443
+ for (const row of rowsFrom(path, lowerBound(path, needle), limit, (row) => probe(row.symbol).startsWith(needle), 50000)) accept(row, 1);
444
+ }
445
+
446
+ // Pass 4: UE naming convention. Agents search `GameMode`, the code says `ALpGameModeBase`, and a
447
+ // tool call rarely spells the prefix letter: one exact probe per conventional letter covers the
448
+ // whole family at index speed (7 binary searches, no scan).
449
+ if (!out.length && !member) {
450
+ for (const letter of ["a", "u", "f", "e", "s", "t", "i"]) {
451
+ const typed = letter + needle;
452
+ for (const row of rowsFrom(path, lowerBound(path, typed), limit, (row) => probe(row.symbol) === typed, 4000)) accept(row, 1);
453
+ if (out.length) break;
454
+ }
455
+ }
456
+
457
+ // Pass 5: substring, opt-in. A linear pass over the TSV (~0.3 s for the 69 MB engine index) that
458
+ // is only reached when every indexed pass missed, so the cost is paid by misses, not by hits.
459
+ if (!out.length && options.substring) {
460
+ for (const row of readDefRows(index)) {
461
+ if (!probe(row.symbol).includes(needle)) continue;
462
+ accept(row, 3);
463
+ if (out.length >= limit) break;
464
+ }
465
+ }
466
+ return out.sort((a, b) => a.rank - b.rank || a.scope.localeCompare(b.scope) || a.symbol.localeCompare(b.symbol)).slice(0, limit);
467
+ }
468
+
469
+ /** Symbol names starting with a prefix — the cheap answer to "what is `Lp` in this project". */
470
+ export function listSymbols(index: IndexState, prefix: string, options: { limit?: number; kind?: string; module?: string } = {}): { symbol: string; kind: string; file: string; line: number; module: string }[] {
471
+ const path = defPath(index);
472
+ const needle = probe(prefix);
473
+ const limit = Math.max(1, options.limit ?? 100);
474
+ const out: { symbol: string; kind: string; file: string; line: number; module: string }[] = [];
475
+ for (const row of rowsFrom(path, lowerBound(path, needle), limit * 10, (row) => probe(row.symbol).startsWith(needle))) {
476
+ if (options.kind && row.kind !== options.kind) continue;
477
+ if (options.module && row.module !== options.module) continue;
478
+ out.push({ symbol: row.symbol, kind: row.kind, file: row.file, line: row.line, module: row.module });
479
+ if (out.length >= limit) break;
480
+ }
481
+ return out;
482
+ }
483
+
484
+ /** Symbols declared in one file, in file order (a linear pass over a 69 MB TSV: ~0.3 s). */
485
+ export function symbolsInFile(index: IndexState, file: string, limit: number): DefRow[] {
486
+ const target = probe(file);
487
+ const path = defPath(index);
488
+ const out: DefRow[] = [];
489
+ for (const row of readDefRows(index)) {
490
+ if (probe(row.file) === target) out.push(row);
491
+ if (out.length >= limit) break;
492
+ }
493
+ return out;
494
+ }
495
+
496
+ /* ──────────────────────────────────────────────────────────────────────── modules and files */
497
+
498
+ export interface ModuleInfo {
499
+ name: string;
500
+ scope: string;
501
+ files: number;
502
+ symbols: number;
503
+ directory: string;
504
+ publicHeaderCount: number;
505
+ sourceCount: number;
506
+ }
507
+
508
+ /** Module inventory, recovered from files.tsv; `directory` is the shallowest directory seen. */
509
+ export function listModules(index: IndexState, options: { limit?: number } = {}): ModuleInfo[] {
510
+ const rows = readFileRows(index);
511
+ const scopes = new Map<string, string>();
512
+ for (const row of readDefRows(index)) if (!scopes.has(row.module) && row.scope) scopes.set(row.module, row.scope);
513
+ const map = new Map<string, ModuleInfo>();
514
+ for (const row of rows) {
515
+ const directory = row.file.split("/").slice(0, -1).join("/");
516
+ const info = map.get(row.module) ?? { name: row.module, scope: scopes.get(row.module) ?? "", files: 0, symbols: 0, directory, publicHeaderCount: 0, sourceCount: 0 };
517
+ info.files += 1;
518
+ info.symbols += row.symbols;
519
+ if (directory.length < info.directory.length) info.directory = directory;
520
+ if (/\/public\//i.test(row.file)) info.publicHeaderCount += 1;
521
+ if (/\.(cpp|cc|c|inl|mm)$/i.test(row.file)) info.sourceCount += 1;
522
+ map.set(row.module, info);
523
+ }
524
+ return [...map.values()].sort((a, b) => b.symbols - a.symbols).slice(0, options.limit ?? 1000);
525
+ }
526
+
527
+ /** Files of a module, public headers first — "what does module X expose". */
528
+ /**
529
+ * Resolve a partial path (`LpCharacterTypes.h`, `Public/Character/LpCharacterTypes.h`) against the
530
+ * indexed files. Agents pass what they see in an error message, not an absolute path.
531
+ */
532
+ export function findFileBySuffix(index: IndexState, suffix: string, limit = 6): string[] {
533
+ const want = normalizePath(suffix).toLowerCase();
534
+ return readFileRows(index)
535
+ .filter((row) => row.file.toLowerCase().endsWith(want) || row.file.toLowerCase().includes("/" + want))
536
+ .slice(0, limit)
537
+ .map((row) => row.file);
538
+ }
539
+
540
+ export function listFiles(index: IndexState, module: string, options: { kind?: string; limit?: number } = {}): { file: string; module: string; kind: string; symbols: number }[] {
541
+ const rows = readFileRows(index);
542
+ const limit = options.limit ?? 300;
543
+ const wanted = module.toLowerCase();
544
+ return rows
545
+ .filter((row) => row.module.toLowerCase() === wanted && (!options.kind || row.kind === options.kind))
546
+ .sort((a, b) => (/\/public\//i.test(a.file) ? -1 : 1) - (/\/public\//i.test(b.file) ? -1 : 1) || a.file.localeCompare(b.file))
547
+ .slice(0, limit);
548
+ }
549
+
550
+ /* ──────────────────────────────────────────────────────────────────────── compile unit */
551
+
552
+ export interface ContextUnit {
553
+ file: string;
554
+ module: string;
555
+ kind: string;
556
+ defines: string[];
557
+ definesFile: string;
558
+ includePaths: string[];
559
+ standard: string;
560
+ compiler: string;
561
+ compilerArgs: string[];
562
+ forcedIncludes: string[];
563
+ includes: string[];
564
+ declares: string[];
565
+ ownerOf: { symbol: string; file: string; line: number }[];
566
+ moduleRefs: { public: string[]; private: string[]; publicInclude: string[]; privateInclude: string[]; dynamicallyLoaded: string[]; circular: string[] };
567
+ notes: string[];
568
+ }
569
+
570
+ /**
571
+ * The compile unit: what one file is compiled with and what it sees. Answers "why is this symbol
572
+ * invisible here" without opening the build graph. Include paths, defines, the C++ standard and the
573
+ * compiler come from the cached `TargetDetails` JSON; defines are read out of the `Definitions*.h`
574
+ * forced include, because TargetDetails reports `defines: []` for every module.
575
+ */
576
+ export function contextFor(index: IndexState, file: string, options: { limit?: number; queryFiles?: { targetDetails: string; moduleReferences: string } } = {}): ContextUnit | undefined {
577
+ const target = normalizePath(file);
578
+ const rows = readFileRows(index);
579
+ const hit = rows.find((row) => row.file === target) ?? rows.find((row) => row.file.toLowerCase() === target.toLowerCase());
580
+ if (!hit) return undefined;
581
+
582
+ const notes: string[] = [];
583
+ const limit = options.limit ?? 200;
584
+ const unit: ContextUnit = {
585
+ file: hit.file,
586
+ module: hit.module,
587
+ kind: hit.kind,
588
+ defines: [],
589
+ definesFile: "",
590
+ includePaths: [],
591
+ standard: "",
592
+ compiler: "",
593
+ compilerArgs: [],
594
+ forcedIncludes: [],
595
+ includes: [],
596
+ declares: [],
597
+ ownerOf: [],
598
+ moduleRefs: { public: [], private: [], publicInclude: [], privateInclude: [], dynamicallyLoaded: [], circular: [] },
599
+ notes,
600
+ };
601
+
602
+ if (index.meta.includesPath && existsSync(index.meta.includesPath)) {
603
+ for (const line of readFileSync(index.meta.includesPath, "utf8").split("\n").slice(1)) {
604
+ const cells = line.split("\t");
605
+ if (cells[0] && unesc(cells[0]) === hit.file) {
606
+ unit.includes = (cells[2] ?? "").split(",").filter(Boolean).slice(0, limit);
607
+ break;
608
+ }
609
+ }
610
+ } else {
611
+ notes.push("includes.tsv absent (definitions index) — `unreal_index build --full` adds per-file include sets");
612
+ }
613
+
614
+ const declared = symbolsInFile(index, hit.file, limit);
615
+ unit.declares = declared.map((row) => row.symbol);
616
+ for (const row of declared.filter((row) => row.kind === "function")) {
617
+ const owner = splitQualified(row.symbol).owner;
618
+ if (!owner) continue;
619
+ const [ownerRow] = lookupSymbol(index, owner, { limit: 1 });
620
+ if (ownerRow) unit.ownerOf.push({ symbol: owner, file: ownerRow.file, line: ownerRow.line });
621
+ }
622
+
623
+ const queryFile = options.queryFiles?.targetDetails ?? index.meta.queryFiles?.targetDetails;
624
+ if (queryFile && existsSync(queryFile)) {
625
+ const settings = parseTargetDetails(readQueryJson(queryFile)).settings.get(hit.module);
626
+ if (settings) applySettings(unit, settings, notes);
627
+ else notes.push(`TargetDetails has no compile settings for module ${hit.module} (target identity mismatch, or a module UBT did not build)`);
628
+ } else {
629
+ notes.push("no cached TargetDetails JSON — include paths, defines and the compiler need `unreal_index build --full` (it runs UnrealBuildTool Query)");
630
+ }
631
+
632
+ const refsFile = options.queryFiles?.moduleReferences ?? index.meta.queryFiles?.moduleReferences;
633
+ if (refsFile && existsSync(refsFile)) {
634
+ const refs = parseModuleReferences(readQueryJson(refsFile)).get(hit.module);
635
+ if (refs) unit.moduleRefs = { public: refs.public, private: refs.private, publicInclude: refs.publicInclude, privateInclude: refs.privateInclude, dynamicallyLoaded: refs.dynamicallyLoaded, circular: refs.circular };
636
+ }
637
+ return unit;
638
+ }
639
+
640
+ function applySettings(unit: ContextUnit, settings: UbtModuleSettings, notes: string[]): void {
641
+ unit.includePaths = settings.includePaths;
642
+ unit.standard = settings.standard;
643
+ unit.compiler = settings.compilerPath;
644
+ unit.compilerArgs = [...settings.compilerArgs, ...settings.compilerAdditionalArgs.cpp];
645
+ unit.forcedIncludes = settings.forcedIncludes;
646
+ unit.definesFile = settings.definesFile;
647
+ if (settings.defines.length) unit.defines = settings.defines;
648
+ if (settings.definesFile && existsSync(settings.definesFile)) {
649
+ unit.defines = readDefines(settings.definesFile);
650
+ notes.push(`defines read from the forced include ${settings.definesFile} (TargetDetails 'defines' is empty for every module)`);
651
+ } else if (!settings.defines.length) {
652
+ notes.push("defines unavailable: TargetDetails reports none and the Definitions header is not on disk (build the target once to generate it)");
653
+ }
654
+ }
655
+
656
+ /** `#define X` / `#define X Y` from a `Definitions*.h` header. */
657
+ export function readDefines(path: string): string[] {
658
+ const out: string[] = [];
659
+ let text: string;
660
+ try {
661
+ text = readFileSync(path, "utf8");
662
+ } catch {
663
+ return out;
664
+ }
665
+ for (const line of text.split(/\r?\n/)) {
666
+ const match = /^\s*#\s*define\s+([A-Za-z_][A-Za-z0-9_]*)\b(.*)$/.exec(line);
667
+ if (!match?.[1]) continue;
668
+ const value = (match[2] ?? "").trim();
669
+ out.push(value === "1" ? match[1] : value ? `${match[1]}=${value}` : match[1]);
670
+ }
671
+ return out;
672
+ }
673
+
674
+ /** Module dependency lists from cached Query data, without a symbol index (definitions-only mode). */
675
+ export function moduleRefsFromQuery(queryFiles: { moduleReferences: string }, module: string): UbtModuleRefs | undefined {
676
+ if (!existsSync(queryFiles.moduleReferences)) return undefined;
677
+ return parseModuleReferences(readQueryJson(queryFiles.moduleReferences)).get(module);
678
+ }
679
+
680
+ /* ──────────────────────────────────────────────────────────────────────── registry */
681
+
682
+ /** Index key: mode + sorted roots (full adds the target identity, because include sets are target-specific). */
683
+ export function indexKeyFor(input: { mode: "definitions" | "full"; roots: string[]; scope?: IndexScope; target?: string; platform?: string; configuration?: string }): string {
684
+ const roots = input.roots.map((root) => normalizePath(root).toLowerCase()).sort();
685
+ const parts = [input.mode, input.scope ?? "", ...roots];
686
+ if (input.mode === "full") parts.push(input.target ?? "", input.platform ?? "", input.configuration ?? "");
687
+ const fingerprint = shortHash(parts.join("|"));
688
+ const label = input.scope === "Engine" ? "engine" : (input.roots[0] ?? roots[0] ?? "index").split("/").filter(Boolean).pop() ?? "index";
689
+ return `idx-${label.replace(/[^a-z0-9]/gi, "")}-${input.mode === "full" ? "full" : "defs"}-${fingerprint}`;
690
+ }
691
+
692
+ export function indexesRoot(): string {
693
+ return normalizePath(joinPath(cacheDir(), "indexes"));
694
+ }
695
+
696
+ export function indexDir(key: string): string {
697
+ const dir = normalizePath(joinPath(indexesRoot(), key));
698
+ mkdirSync(dir, { recursive: true });
699
+ return dir;
700
+ }
701
+
702
+ /** Every index on disk, newest first, with health (status, `--rebuild`, and the auto path). */
703
+ export function listIndexes(): IndexState[] {
704
+ const root = indexesRoot();
705
+ if (!existsSync(root)) return [];
706
+ const out: IndexState[] = [];
707
+ for (const key of readdirSync(root)) {
708
+ const meta = readJson<IndexMeta>(joinPath(root, key, "meta.json"));
709
+ if (!meta || !meta.defsPath) continue;
710
+ const issues: string[] = [];
711
+ if (meta.version !== INDEX_VERSION) issues.push(`format v${meta.version}, current v${INDEX_VERSION}`);
712
+ if (!existsSync(meta.defsPath)) issues.push("defs.tsv missing");
713
+ if (meta.mode === "full" && (!meta.includesPath || !existsSync(meta.includesPath))) issues.push("includes.tsv missing");
714
+ for (const r of meta.roots ?? []) if (!existsSync(r)) issues.push(`root no longer present: ${r}`);
715
+ let ageMs = 0;
716
+ try {
717
+ ageMs = Date.now() - statSync(joinPath(root, key)).mtimeMs;
718
+ } catch {
719
+ /* unreadable directory: report age 0 */
720
+ }
721
+ out.push({ key, dir: normalizePath(joinPath(root, key)), meta, fs: realFs, ageMs, healthy: issues.length === 0, issues, scope: meta.scope ?? deriveScope(meta.roots ?? [], { projectRoot: meta.projectRoot, engineRoot: meta.engineRoot }) });
722
+ }
723
+ return out.sort((a, b) => b.ageMs - a.ageMs);
724
+ }
725
+
726
+ export function loadIndex(key: string): IndexState | undefined {
727
+ const dir = normalizePath(joinPath(indexesRoot(), key));
728
+ const meta = readJson<IndexMeta>(joinPath(dir, "meta.json"));
729
+ if (!meta) return undefined;
730
+ let ageMs = 0;
731
+ try {
732
+ ageMs = Date.now() - statSync(dir).mtimeMs;
733
+ } catch {
734
+ /* caller reports the missing directory */
735
+ }
736
+ return { key, dir, meta, fs: realFs, ageMs, healthy: meta.version === INDEX_VERSION && existsSync(meta.defsPath), issues: meta.version === INDEX_VERSION ? [] : [`format v${meta.version}, current v${INDEX_VERSION}`], scope: meta.scope ?? deriveScope(meta.roots ?? [], { projectRoot: meta.projectRoot, engineRoot: meta.engineRoot }) };
737
+ }
738
+
739
+ /** The index a query should use: explicit key, else the newest healthy index matching the filter. */
740
+ /** Same root set, same index. Path-compare is case-insensitive because Windows is. */
741
+ export const rootsSignature = (roots: string[]) => roots.map((root) => normalizePath(root).toLowerCase()).sort().join("|");
742
+
743
+ export function pickIndex(filter: { key?: string; mode?: "definitions" | "full"; projectRoot?: string; engineRoot?: string; scope?: IndexScope; roots?: string[] }): { index?: IndexState; candidates: IndexState[]; notes: string[] } {
744
+ const notes: string[] = [];
745
+ const all = listIndexes();
746
+ if (filter.key) {
747
+ const found = all.find((state) => state.key === filter.key);
748
+ if (found) return { index: found, candidates: all, notes };
749
+ notes.push(`no index named ${filter.key}`);
750
+ return { candidates: all, notes };
751
+ }
752
+ let pool = all.filter((state) => state.healthy);
753
+ if (filter.mode) pool = pool.filter((state) => state.meta.mode === filter.mode);
754
+ if (filter.projectRoot) pool = pool.filter((state) => normalizePath(state.meta.projectRoot ?? "").toLowerCase() === normalizePath(filter.projectRoot ?? "").toLowerCase());
755
+ if (filter.scope) pool = pool.filter((state) => state.scope === filter.scope);
756
+ // Scope alone is not a coverage claim: an index built over Engine/Source/Core answers a
757
+ // Runtime-wide query with a partial list, and a partial list is worse than no list.
758
+ const want = filter.roots ? rootsSignature(filter.roots) : "";
759
+ if (want) {
760
+ pool = pool.filter((state) => rootsSignature(state.meta.roots ?? []) === want);
761
+ if (!pool.length && all.some((state) => state.healthy && (!filter.mode || state.meta.mode === filter.mode) && (!filter.scope || state.scope === filter.scope))) {
762
+ notes.push(`an index exists for the same scope over a different root set (${all.map((state) => state.key).join(", ")}); it is not reused, build for these roots`);
763
+ }
764
+ }
765
+ const engineRoot = filter.engineRoot ? normalizePath(filter.engineRoot).toLowerCase() : "";
766
+ if (!pool.length && engineRoot) pool = all.filter((state) => state.healthy && normalizePath(state.meta.engineRoot ?? "").toLowerCase() === engineRoot && (!filter.scope || state.scope === filter.scope) && (!want || rootsSignature(state.meta.roots ?? []) === want));
767
+ if (!pool.length) notes.push(all.length ? `${all.length} index/indices exist, none healthy and matching this scope` : "no symbol index built yet");
768
+ return { index: pool[0], candidates: pool, notes };
769
+ }
770
+
771
+ /* ──────────────────────────────────────────────────────────────────────── helpers */
772
+
773
+ const fileKindLabel = (file: string) => (/\/public\//i.test(file) ? "public-header" : /\.(cpp|cc|c|mm|cpp20)$/i.test(file) ? "source" : /\/internal\//i.test(file) ? "internal-header" : "header");
774
+
775
+ function readJson<T>(path: string): T | undefined {
776
+ try {
777
+ return JSON.parse(readFileSync(path, "utf8")) as T;
778
+ } catch {
779
+ return undefined;
780
+ }
781
+ }
782
+
783
+ /** Write-then-rename, so an interrupted build never leaves a half-written TSV behind. */
784
+ function writeTextAtomic(path: string, text: string): void {
785
+ const tmp = `${path}.tmp`;
786
+ writeFileSync(tmp, text, "utf8");
787
+ if (existsSync(path)) {
788
+ try {
789
+ renameSync(tmp, path);
790
+ } catch {
791
+ writeFileSync(path, text, "utf8");
792
+ try {
793
+ renameSync(tmp, path);
794
+ } catch {
795
+ /* Windows locking (an open reader): the first writeFileSync already landed */
796
+ }
797
+ }
798
+ return;
799
+ }
800
+ renameSync(tmp, path);
801
+ }
802
+
803
+ function writeJsonAtomic(path: string, value: unknown): void {
804
+ writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
805
+ }
806
+
807
+ /** Module attribution without Query data: the nearest `*.Build.cs` above the file. */
808
+ export function moduleFromBuildCs(file: string): string {
809
+ const parts = normalizePath(file).split("/");
810
+ for (let i = parts.length - 2; i >= 0; i--) {
811
+ const name = parts[i];
812
+ if (name && existsSync(`${parts.slice(0, i + 1).join("/")}/${name}.Build.cs`)) return name;
813
+ }
814
+ const marker = parts.indexOf("Source");
815
+ const candidate = marker >= 0 ? parts[marker + 1] : undefined;
816
+ if (candidate) return candidate;
817
+ return parts[parts.length - 2] ?? "root";
818
+ }
819
+
820
+ /**
821
+ * Scan roots for an index build. A "root" is one module directory (the directory holding a
822
+ * `*.Build.cs`), because that is the unit UBT compiles and the unit a symbol's module belongs to.
823
+ *
824
+ * Engine roots are limited to the areas that carry game-facing C++ (`Runtime`, `Editor`,
825
+ * `Developer`) by default: `Engine/Source/Programs` (UnrealLightmass, ShaderCompileTool, ...) is
826
+ * tens of thousands of files of build-tool code that a gameplay symbol lookup will never want.
827
+ */
828
+ export interface RootOptions {
829
+ projectRoot?: string;
830
+ engineRoot?: string;
831
+ /** Plugin directories (absolute), e.g. from `PluginSummary.dir`. */
832
+ pluginDirs?: string[];
833
+ includeEngine?: boolean;
834
+ /** Sub-directories of `Engine/Source` to scan. Default `DEFAULT_ENGINE_AREAS` (`["Runtime"]`). */
835
+ engineAreas?: string[];
836
+ fs?: FsAdapter;
837
+ }
838
+
839
+ /** Module directories (each contains a `*.Build.cs`) directly under `dir`. */
840
+ /**
841
+ * Module roots under `dir`: each immediate child that contains a `*.Build.cs`.
842
+ * If `dir` itself is a module directory (`Engine/Source/Runtime/CoreUObject`) it is the root.
843
+ */
844
+ const SKIP_DIR_NAMES = new Set(["Intermediate", "Binaries", "Saved", ".git", ".vs", "ThirdParty", "Programs", "Shaders", "Config", "Content", "Build"]);
845
+
846
+ /**
847
+ * A module directory is any directory under `dir` that owns a `*.Build.cs`, at any depth. UE puts
848
+ * modules at `Runtime/Core`, `Runtime/Engine/Classes`... and areas are addressable as
849
+ * `engineAreas: ["Runtime/Core"]`, so the search must recurse (depth-capped, build-system dirs skipped).
850
+ */
851
+ function moduleDirsUnder(dir: string, scope: ScanRoot["scope"], fs: FsAdapter, maxDepth = 3, depth = 0): ScanRoot[] {
852
+ const roots: ScanRoot[] = [];
853
+ const here = normalizePath(dir);
854
+ if (fs.listDir(here).some((entry) => entry.endsWith(".Build.cs"))) {
855
+ roots.push({ dir: here, scope, module: baseName(here) });
856
+ return roots; // a module's own tree never contains another module
857
+ }
858
+ if (depth >= maxDepth) return roots;
859
+ for (const child of fs.listDir(here)) {
860
+ const abs = joinPath(here, child);
861
+ if (!fs.isDirectory(abs)) continue;
862
+ if (SKIP_DIR_NAMES.has(child)) continue;
863
+ roots.push(...moduleDirsUnder(abs, scope, fs, maxDepth, depth + 1));
864
+ }
865
+ return roots;
866
+ }
867
+
868
+ export function assembleRoots(options: RootOptions): { roots: ScanRoot[]; notes: string[] } {
869
+ const fs = options.fs ?? realFs;
870
+ const notes: string[] = [];
871
+ const roots: ScanRoot[] = [];
872
+
873
+ if (options.projectRoot) {
874
+ const projectRoot = normalizePath(options.projectRoot);
875
+ const found = moduleDirsUnder(joinPath(projectRoot, "Source"), "Project", fs);
876
+ roots.push(...found);
877
+ notes.push(found.length ? `project: ${found.length} module root(s) under ${projectRoot}/Source` : `no module roots under ${projectRoot}/Source`);
878
+ }
879
+
880
+ for (const pluginDir of options.pluginDirs ?? []) {
881
+ const abs = normalizePath(pluginDir);
882
+ const found = moduleDirsUnder(joinPath(abs, "Source"), "Plugin", fs);
883
+ roots.push(...found);
884
+ if (!found.length) notes.push(`plugin ${baseName(abs)}: no C++ module roots`);
885
+ }
886
+
887
+ if (options.includeEngine && options.engineRoot) {
888
+ const engineRoot = normalizePath(options.engineRoot);
889
+ const areas = options.engineAreas?.length ? options.engineAreas : DEFAULT_ENGINE_AREAS;
890
+ for (const area of areas) {
891
+ const found = moduleDirsUnder(joinPath(engineRoot, "Engine/Source", area), "Engine", fs, 4);
892
+ roots.push(...found);
893
+ notes.push(`engine ${area}: ${found.length} module root(s)`);
894
+ }
895
+ }
896
+
897
+ return { roots, notes };
898
+ }
899
+
900
+ export type { IncludeRef, ScanRoot, SymbolDef };