@aeonsparx/pi-unreal 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1490 @@
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, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
27
+ import { StringDecoder } from "node:string_decoder";
28
+ import { createInterface } from "node:readline";
29
+ import { cacheDir } from "./cache.ts";
30
+ import { normalizePath, joinPath, shortHash, baseName, realFs, type FsAdapter } from "./util.ts";
31
+ import { collectSourceFiles, scanText, type IncludeRef, type ScanRoot, type SymbolDef } from "./scan.ts";
32
+ import { parseModuleReferences, parseTargetDetails, readQueryJson, type UbtModuleRefs, type UbtModuleSettings } from "./ubt.ts";
33
+
34
+ /**
35
+ * v6: `meta.rootKinds` (what an index actually covers, by root kind) and `meta.areas`, and engine-scope
36
+ * indexes now include the project's enabled plugin roots, so engine-scope keys change and v5 engine
37
+ * indexes are not reused. v5 was the TSV layout change; older indexes are unreadable by design.
38
+ */
39
+ export const INDEX_VERSION = 6;
40
+
41
+ /**
42
+ * Default engine scan. `Runtime` is where the **engine's own** gameplay types live (AActor,
43
+ * UAnimInstance, UGameModeBase): measured 6,212 files / 126,350 definitions in 34 s on a UE 5.8
44
+ * install. `Editor` and `Developer` roughly triple that, so they are opt-in through `engineAreas`.
45
+ *
46
+ * It is NOT the whole engine: most UE-facing types shipped as engine *plugins* (Mover, PoseSearch,
47
+ * PhysicsControl, BlendStack, SmartObjects, StateTree, GameplayAbilities, GameplayStateTree, …) live
48
+ * under Engine/Plugins/<category>/<plugin>/Source/<module>, which `"Runtime"` does not touch. Engine-scope
49
+ * indexes therefore add the plugin roots the project enables (free — already computed), and
50
+ * `engineAreas: ["Plugins"]` (or `["Plugins/Animation/PhysicsControl"]`) dials in the rest. Measured on
51
+ * UE 5.8: Engine/Source/Runtime is 18,681 scannable files, all plugin sources are 52,568 (~2.8x).
52
+ */
53
+ export const DEFAULT_ENGINE_AREAS = ["Runtime"];
54
+
55
+ export type IndexScope = "Engine" | "Project" | "Plugin" | "";
56
+
57
+ export interface IndexMeta {
58
+ version: number;
59
+ key: string;
60
+ mode: "definitions" | "full";
61
+ /** `ubt` = module attribution from Query data; `heuristic` = nearest `*.Build.cs`. */
62
+ source: "ubt" | "heuristic";
63
+ /** Provenance of the indexed roots: `Engine` = engine sources only, `Project` = project + plugins. */
64
+ scope: IndexScope;
65
+ generatedAt: string;
66
+ roots: string[];
67
+ engineRoot?: string;
68
+ /** Index that supplied the engine half of a project index. */
69
+ engineIndexKey?: string;
70
+ projectRoot?: string;
71
+ projectName?: string;
72
+ target?: string;
73
+ platform?: string;
74
+ configuration?: string;
75
+ counts: { files: number; symbols: number; includes: number; modules: number };
76
+ defsByKind: Record<string, number>;
77
+ /** Root provenance by kind — the coverage claim a 0-row answer is bounded by (see `classifyRoot`). */
78
+ rootKinds?: Record<string, number>;
79
+ /** `engineAreas` the engine half was scanned with, when an engine half exists. */
80
+ areas?: string[];
81
+ warnings: string[];
82
+ sizeBytes: number;
83
+ defsPath: string;
84
+ filesPath: string;
85
+ includesPath?: string;
86
+ queryFiles?: { targetDetails: string; moduleReferences: string };
87
+ queryDurationsMs?: { targetDetails: number; moduleReferences: number };
88
+ }
89
+
90
+ export interface IndexState {
91
+ key: string;
92
+ dir: string;
93
+ meta: IndexMeta;
94
+ fs: FsAdapter;
95
+ ageMs: number;
96
+ healthy: boolean;
97
+ issues: string[];
98
+ /** `meta.scope`, derived from the roots when an older meta.json has none. */
99
+ scope: IndexScope;
100
+ }
101
+
102
+ export interface IndexBuild {
103
+ key: string;
104
+ dir: string;
105
+ mode: IndexMeta["mode"];
106
+ source: IndexMeta["source"];
107
+ scope: IndexMeta["scope"];
108
+ roots: string[];
109
+ counts: IndexMeta["counts"];
110
+ defsByKind: Record<string, number>;
111
+ warnings: string[];
112
+ durationMs: number;
113
+ meta: IndexMeta;
114
+ }
115
+
116
+ export type DefRow = { symbol: string; kind: string; file: string; line: number; module: string; scope: string; marker: string };
117
+
118
+ /* ──────────────────────────────────────────────────────────────────── root kinds */
119
+
120
+ /**
121
+ * What kind of source a scan root is. This is the vocabulary every coverage claim is written in:
122
+ * `unreal_symbol scope: "engine"` answers for `engine-source` + `engine-plugin` roots, and nothing
123
+ * else — a 0-row answer is only a statement about the roots that were scanned.
124
+ */
125
+ export type RootKind = "project" | "engine-plugin" | "engine-source" | "other-plugin";
126
+
127
+ export function classifyRoot(root: string, options: { projectRoot?: string; engineRoot?: string } = {}): RootKind {
128
+ const r = normalizePath(root).toLowerCase();
129
+ const project = options.projectRoot ? normalizePath(options.projectRoot).toLowerCase() : undefined;
130
+ const engine = options.engineRoot ? normalizePath(options.engineRoot).toLowerCase() : undefined;
131
+ if (project && (r === project || r.startsWith(`${project}/`))) return "project";
132
+ if (engine && r.startsWith(`${engine}/`)) {
133
+ if (r.includes("/engine/plugins/")) return "engine-plugin";
134
+ if (r.includes("/engine/source/")) return "engine-source";
135
+ return "other-plugin";
136
+ }
137
+ return "other-plugin";
138
+ }
139
+
140
+ /** Root counts by kind, most numerous first. Drives index-status coverage lines and 0-row caveats. */
141
+ export function summarizeRootKinds(roots: string[], options: { projectRoot?: string; engineRoot?: string } = {}): { kind: RootKind; count: number }[] {
142
+ const counts = new Map<RootKind, number>();
143
+ for (const root of roots) {
144
+ const kind = classifyRoot(root, options);
145
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
146
+ }
147
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([kind, count]) => ({ kind, count }));
148
+ }
149
+
150
+ /** One-line coverage phrase, e.g. `1 project root, 62 engine-plugin roots`. */
151
+ export function rootKindsNote(roots: string[], options: { projectRoot?: string; engineRoot?: string } = {}): string {
152
+ const parts = summarizeRootKinds(roots, options).map((entry) => `${entry.count} ${entry.kind} root(s)`);
153
+ return parts.length ? parts.join(", ") : "no roots";
154
+ }
155
+
156
+ /**
157
+ * Provenance of a set of scan roots. Plugin module directories inside an installed engine tree count as
158
+ * project-side (`.../Engine/Plugins/...`), so a project + plugins index classifies as `Project` and an
159
+ * Engine/Source index classifies as `Engine`. Mixed roots give "" — the index is not scoped.
160
+ */
161
+ export function deriveScope(roots: string[], options: { projectRoot?: string; engineRoot?: string } = {}): IndexScope {
162
+ const project = options.projectRoot ? normalizePath(options.projectRoot).toLowerCase() : undefined;
163
+ const engine = options.engineRoot ? normalizePath(options.engineRoot).toLowerCase() : undefined;
164
+ let engineSide = 0;
165
+ let projectSide = 0;
166
+ let pluginSide = 0;
167
+ for (const root of roots) {
168
+ const r = normalizePath(root).toLowerCase();
169
+ if (project && (r === project || r.startsWith(`${project}/`))) {
170
+ projectSide++;
171
+ continue;
172
+ }
173
+ if (engine && r.startsWith(`${engine}/`)) {
174
+ if (r.includes("/engine/plugins/")) pluginSide++;
175
+ else engineSide++;
176
+ continue;
177
+ }
178
+ if (r.includes("/engine/plugins/")) pluginSide++;
179
+ }
180
+ if (engineSide && (projectSide || pluginSide)) return "";
181
+ if (engineSide) return "Engine";
182
+ if (projectSide) return "Project";
183
+ if (pluginSide) return "Plugin";
184
+ return "";
185
+ }
186
+
187
+ export interface LookupResult extends DefRow {
188
+ /** 0 = exact, 1 = prefix, 2 = qualified-member fallback. */
189
+ rank: number;
190
+ }
191
+
192
+ const TSV_HEADER = ["symbol", "kind", "file", "line", "module", "scope", "marker"];
193
+
194
+ /* ──────────────────────────────────────────────────────────────────────── build */
195
+
196
+ const esc = (value: string) => (value.includes("\t") || value.includes("\n") ? JSON.stringify(value) : value);
197
+
198
+ /**
199
+ * Build an index from scan roots.
200
+ *
201
+ * `resolveModule` is the module attribution: with Query data it is UBT's `dirToModule` (authoritative,
202
+ * deepest-prefix match); without it, the nearest `*.Build.cs` above the file. The result records which
203
+ * one ran, so a heuristic index never pretends to be UBT-accurate.
204
+ */
205
+ /** Rows are sorted by the probe key (see `probe`): that is the order `lowerBound` binary-searches. */
206
+ function byProbe(a: DefRow, b: DefRow): number {
207
+ const x = probe(a.symbol);
208
+ const y = probe(b.symbol);
209
+ if (x !== y) return x < y ? -1 : 1;
210
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
211
+ return a.line - b.line;
212
+ }
213
+
214
+ /** Rows per sorted shard. 40 000 rows ≈ 5 MB of live objects; a whole-Plugins scan spills ~43. */
215
+ const SHARD_ROWS = 40_000;
216
+
217
+ /** One def row in `defs.tsv` cell order (shared by the shard writer and the merge). */
218
+ function defLine(row: DefRow): string {
219
+ return [esc(row.symbol), row.kind, esc(row.file), row.line, esc(row.module), esc(row.scope), esc(row.marker)].join("\t");
220
+ }
221
+
222
+ /** Plain-string key that reproduces `byProbe` order: probe(symbol), file, then line (zero-padded). */
223
+ function defSortKey(line: string): string {
224
+ const cells = line.split("\t");
225
+ return `${probe(unesc(cells[0] ?? ""))}\t${unesc(cells[2] ?? "")}\t${String(Number(cells[3]) || 0).padStart(12, "0")}`;
226
+ }
227
+
228
+ /** Buffered append-only TSV writer (a whole file is never built as one string). */
229
+ class TsvStreamWriter {
230
+ private fd: number;
231
+ private lines: string[] = [];
232
+ bytes = 0;
233
+
234
+ constructor(readonly path: string) {
235
+ mkdirSync(baseDirOf(path), { recursive: true });
236
+ this.fd = openSync(path, "w");
237
+ }
238
+
239
+ write(line: string): void {
240
+ this.lines.push(line);
241
+ if (this.lines.length >= 8192) this.flush();
242
+ }
243
+
244
+ private flush(): void {
245
+ if (!this.lines.length) return;
246
+ const buffer = Buffer.from(`${this.lines.join("\n")}\n`, "utf8");
247
+ this.lines = [];
248
+ let offset = 0;
249
+ while (offset < buffer.length) offset += writeSync(this.fd, buffer, offset, buffer.length - offset);
250
+ this.bytes += buffer.length;
251
+ }
252
+
253
+ close(): void {
254
+ this.flush();
255
+ closeSync(this.fd);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * K-way merge of sorted shard files into one sorted TSV stream.
261
+ *
262
+ * Each shard is read lazily (one readline iterator per shard, one live line each), so memory is
263
+ * O(shards), and a min-heap over the shard heads keeps the merge at O(rows · log shards).
264
+ */
265
+ async function mergeSortedShards(shardPaths: string[], out: TsvStreamWriter): Promise<void> {
266
+ const streams = shardPaths.map((path) => createInterface({ input: createReadStream(path), crlfDelay: Infinity })[Symbol.asyncIterator]());
267
+ const heads: ({ key: string; line: string } | undefined)[] = streams.map(() => undefined);
268
+ const alive: number[] = [];
269
+ const pull = async (shard: number): Promise<void> => {
270
+ const next = await streams[shard]!.next();
271
+ heads[shard] = next.done || !next.value ? undefined : { key: defSortKey(next.value), line: next.value };
272
+ };
273
+ const keyAt = (slot: number) => heads[alive[slot]!]!.key;
274
+ const swap = (a: number, b: number) => {
275
+ const tmp = alive[a]!;
276
+ alive[a] = alive[b]!;
277
+ alive[b] = tmp;
278
+ };
279
+ const siftDown = (from: number) => {
280
+ let i = from;
281
+ for (;;) {
282
+ const left = 2 * i + 1;
283
+ const right = left + 1;
284
+ let best = i;
285
+ if (left < alive.length && keyAt(left) < keyAt(best)) best = left;
286
+ if (right < alive.length && keyAt(right) < keyAt(best)) best = right;
287
+ if (best === i) return;
288
+ swap(i, best);
289
+ i = best;
290
+ }
291
+ };
292
+ for (let shard = 0; shard < streams.length; shard++) {
293
+ await pull(shard);
294
+ if (heads[shard]) alive.push(shard);
295
+ }
296
+ for (let slot = (alive.length >> 1) - 1; slot >= 0; slot--) siftDown(slot);
297
+ while (alive.length) {
298
+ const shard = alive[0]!;
299
+ const head = heads[shard]!;
300
+ out.write(head.line);
301
+ await pull(shard);
302
+ if (!heads[shard]) {
303
+ // This shard is exhausted: move the last live shard to the root slot and shrink the heap.
304
+ alive[0] = alive[alive.length - 1]!;
305
+ alive.pop();
306
+ }
307
+ if (alive.length) siftDown(0);
308
+ }
309
+ for (const stream of streams) await stream.return?.(undefined);
310
+ }
311
+
312
+ function baseDirOf(path: string): string {
313
+ const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
314
+ return cut <= 0 ? path : path.slice(0, cut);
315
+ }
316
+
317
+ export async function buildIndex(input: {
318
+ key: string;
319
+ mode: "definitions" | "full";
320
+ roots: ScanRoot[];
321
+ engineRoot?: string;
322
+ engineIndexKey?: string;
323
+ projectRoot?: string;
324
+ projectName?: string;
325
+ target?: string;
326
+ platform?: string;
327
+ configuration?: string;
328
+ queryFiles?: { targetDetails: string; moduleReferences: string };
329
+ /** Recorded verbatim in `meta.areas` so status can state what an engine index scanned. */
330
+ areas?: string[];
331
+ /** Where the ScanRoot provenance comes from; `deriveScope(roots)` when omitted. */
332
+ scope?: IndexScope;
333
+ concurrency?: number;
334
+ /**
335
+ * Rows per sorted shard (default `SHARD_ROWS`). Exposed for tests: a whole-Plugins scan spills ~43
336
+ * shards and 1.24 M rows, and a merge bug is invisible at the production size.
337
+ */
338
+ shardRows?: number;
339
+ }): Promise<IndexBuild> {
340
+ const started = Date.now();
341
+ const dir = indexDir(input.key);
342
+ const warnings: string[] = [];
343
+ const query = input.queryFiles && existsSync(input.queryFiles.targetDetails) ? parseTargetDetails(readQueryJson(input.queryFiles.targetDetails)) : undefined;
344
+ const refs = input.queryFiles && existsSync(input.queryFiles.moduleReferences) ? parseModuleReferences(readQueryJson(input.queryFiles.moduleReferences)) : undefined;
345
+ if (input.queryFiles && !query) warnings.push(`Query JSON unreadable at ${input.queryFiles.targetDetails}; module attribution falls back to *.Build.cs locations`);
346
+
347
+ const dirToModule = query ? [...query.dirModules].sort((a, b) => b.prefix.length - a.prefix.length) : [];
348
+ const moduleOf = (file: string, root: ScanRoot): string => {
349
+ const hit = dirToModule.find((entry) => file.startsWith(entry.prefix.endsWith("/") ? entry.prefix : `${entry.prefix}/`));
350
+ if (hit) return hit.module;
351
+ if (root.module) return root.module;
352
+ return moduleFromBuildCs(file);
353
+ };
354
+
355
+ const rootDirs = input.roots.map((root) => root.dir);
356
+ const rootKinds: Record<string, number> = {};
357
+ for (const entry of summarizeRootKinds(rootDirs, { projectRoot: input.projectRoot, engineRoot: input.engineRoot })) {
358
+ rootKinds[entry.kind] = entry.count;
359
+ }
360
+
361
+ const files = await collectSourceFiles(input.roots, { concurrency: input.concurrency });
362
+ // Streaming write: a whole-Plugins engine index is ~1.7 M definition rows (~300 MB of TSV), and
363
+ // accumulating them in an array before one giant sort was the OOM. Rows are sorted per shard and
364
+ // the shards are merged, so peak memory is one shard, and defs.tsv is byte-identical to a sorted
365
+ // single-pass write.
366
+ const shardDir = joinPath(dir, "shards");
367
+ const shardRows = Math.max(1, input.shardRows ?? SHARD_ROWS);
368
+ rmSync(shardDir, { recursive: true, force: true });
369
+ mkdirSync(shardDir, { recursive: true });
370
+ const shardPaths: string[] = [];
371
+ let batch: DefRow[] = [];
372
+ let rowTotal = 0;
373
+ const flushBatch = () => {
374
+ if (!batch.length) return;
375
+ batch.sort(byProbe);
376
+ const shardPath = joinPath(shardDir, `shard-${String(shardPaths.length).padStart(4, "0")}.tsv`);
377
+ const shard = new TsvStreamWriter(shardPath);
378
+ for (const row of batch) shard.write(defLine(row));
379
+ shard.close();
380
+ shardPaths.push(shardPath);
381
+ rowTotal += batch.length;
382
+ batch = [];
383
+ };
384
+ const filesOut = new TsvStreamWriter(joinPath(dir, "files.tsv"));
385
+ filesOut.write("file\tmodule\tkind\tsymbols");
386
+ const includesOut = input.mode === "full" ? new TsvStreamWriter(joinPath(dir, "includes.tsv")) : undefined;
387
+ includesOut?.write("file\tmodule\tincludes\tsymbols");
388
+ let fileTotal = 0;
389
+ let includeTotal = 0;
390
+ const modules = new Set<string>();
391
+ const defsByKind: Record<string, number> = {};
392
+
393
+ for (const entry of files) {
394
+ let text: string;
395
+ try {
396
+ text = readFileSync(entry.abs, "utf8");
397
+ } catch {
398
+ continue; // locked by a running editor build: skip the file, do not fail the index
399
+ }
400
+ const module = moduleOf(entry.abs, entry.root);
401
+ const { defs, includes } = scanText(text, entry.abs, module, entry.root.scope);
402
+ modules.add(module);
403
+ for (const def of defs) {
404
+ batch.push({ symbol: def.symbol, kind: def.kind, file: def.file, line: def.line, module: def.module, scope: def.scope, marker: def.marker });
405
+ defsByKind[def.kind] = (defsByKind[def.kind] ?? 0) + 1;
406
+ }
407
+ if (batch.length >= shardRows) flushBatch();
408
+ fileTotal++;
409
+ filesOut.write(`${esc(entry.abs)}\t${esc(module)}\t${fileKindLabel(entry.abs)}\t${defs.length}`);
410
+ if (includesOut) {
411
+ includeTotal++;
412
+ includesOut.write(`${esc(entry.abs)}\t${esc(module)}\t${includes.map((inc) => (inc.angled ? `<${inc.target}>` : `"${inc.target}"`)).join(",")}\t${defs.map((def) => def.symbol).join(",")}`);
413
+ }
414
+ }
415
+ flushBatch();
416
+ filesOut.close();
417
+ includesOut?.close();
418
+
419
+ const defsOut = new TsvStreamWriter(joinPath(dir, "defs.tsv"));
420
+ defsOut.write(TSV_HEADER.join("\t"));
421
+ await mergeSortedShards(shardPaths, defsOut);
422
+ defsOut.close();
423
+ rmSync(shardDir, { recursive: true, force: true });
424
+
425
+ const meta: IndexMeta = {
426
+ version: INDEX_VERSION,
427
+ key: input.key,
428
+ mode: input.mode,
429
+ source: query && refs ? "ubt" : "heuristic",
430
+ scope: input.scope ?? deriveScope(input.roots.map((root) => root.dir), { projectRoot: input.projectRoot, engineRoot: input.engineRoot }),
431
+ generatedAt: new Date().toISOString(),
432
+ roots: input.roots.map((root) => normalizePath(root.dir)),
433
+ engineRoot: input.engineRoot ? normalizePath(input.engineRoot) : undefined,
434
+ engineIndexKey: input.engineIndexKey,
435
+ projectRoot: input.projectRoot ? normalizePath(input.projectRoot) : undefined,
436
+ projectName: input.projectName,
437
+ target: input.target,
438
+ platform: input.platform,
439
+ configuration: input.configuration,
440
+ counts: { files: fileTotal, symbols: rowTotal, includes: includeTotal, modules: modules.size },
441
+ defsByKind,
442
+ rootKinds,
443
+ areas: input.areas,
444
+ warnings,
445
+ sizeBytes: defsOut.bytes,
446
+ defsPath: normalizePath(joinPath(dir, "defs.tsv")),
447
+ filesPath: normalizePath(joinPath(dir, "files.tsv")),
448
+ includesPath: input.mode === "full" ? normalizePath(joinPath(dir, "includes.tsv")) : undefined,
449
+ queryFiles: input.queryFiles,
450
+ };
451
+ writeJsonAtomic(joinPath(dir, "meta.json"), meta);
452
+
453
+ 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 };
454
+ }
455
+
456
+ /**
457
+ * Re-scan only the files a caller names (an agent that just edited three files), merging rows into
458
+ * the existing TSV. Kept as a whole-file rewrite: 69 MB rewritten in ~1 s is cheaper than a
459
+ * patchable index format, and the result is always consistent.
460
+ *
461
+ * Memory-bound by design: `readDefRows` materializes the whole table. Safe for project and
462
+ * engine-area indexes; do not run it against a whole-`Engine/Plugins` index (~1.24 M rows, ~300 MB),
463
+ * which is why no tool exposes it.
464
+ */
465
+ export async function patchIndex(index: IndexState, files: string[], resolveModule: (file: string) => { module: string; scope: IndexScope }): Promise<{ updated: number; added: number; removed: number }> {
466
+ const rows = readDefRows(index);
467
+ const byFile = new Map<string, DefRow[]>();
468
+ for (const row of rows) {
469
+ const list = byFile.get(row.file) ?? [];
470
+ list.push(row);
471
+ byFile.set(row.file, list);
472
+ }
473
+ let updated = 0;
474
+ let added = 0;
475
+ let removed = 0;
476
+ for (const file of files.map(normalizePath)) {
477
+ const existing = byFile.get(file);
478
+ if (!existing) added++;
479
+ else removed += existing.length;
480
+ let text: string;
481
+ try {
482
+ text = readFileSync(file, "utf8");
483
+ } catch {
484
+ continue;
485
+ }
486
+ const { module, scope } = resolveModule(file);
487
+ const { defs } = scanText(text, file, module, scope);
488
+ 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 })));
489
+ updated++;
490
+ }
491
+ const merged = [...byFile.values()].flat();
492
+ merged.sort(byProbe);
493
+ 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`);
494
+ lineIndexCache.delete(index.meta.defsPath);
495
+ return { updated, added, removed };
496
+ }
497
+
498
+ /* ──────────────────────────────────────────────────────────────────────── lookup */
499
+
500
+ /** Byte offset of every line start, cached per file: one read, then every lookup is a seek. */
501
+ const lineIndexCache = new Map<string, { offsets: number[]; size: number }>();
502
+
503
+ function lineOffsets(path: string): { offsets: number[]; size: number } {
504
+ const size = statSync(path).size;
505
+ const cached = lineIndexCache.get(path);
506
+ if (cached && cached.size === size) return cached;
507
+ const fd = openSync(path, "r");
508
+ try {
509
+ // Chunked: a whole-Plugins defs.tsv is ~300 MB, and reading it into one Buffer to count
510
+ // newlines is a 300 MB allocation on the first lookup against it.
511
+ const buffer = Buffer.alloc(1 << 22);
512
+ const offsets: number[] = [0];
513
+ let position = 0;
514
+ for (;;) {
515
+ const read = readSync(fd, buffer, 0, buffer.length, position);
516
+ if (read <= 0) break;
517
+ for (let i = 0; i < read; i++) if (buffer[i] === 0x0a) offsets.push(position + i + 1);
518
+ position += read;
519
+ }
520
+ const entry = { offsets, size };
521
+ lineIndexCache.set(path, entry);
522
+ return entry;
523
+ } finally {
524
+ closeSync(fd);
525
+ }
526
+ }
527
+
528
+ function readRange(path: string, start: number, end: number): string {
529
+ const fd = openSync(path, "r");
530
+ try {
531
+ const length = Math.max(0, Math.min(end, statSync(path).size) - start);
532
+ const buffer = Buffer.alloc(length);
533
+ const read = readSync(fd, buffer, 0, length, start);
534
+ return buffer.subarray(0, read).toString("utf8");
535
+ } finally {
536
+ closeSync(fd);
537
+ }
538
+ }
539
+
540
+ /** Parse TSV rows (header skipped). `numericColumn` is parsed as a number. */
541
+ export function readTsv(text: string, numericColumn = -1): Record<string, string | number>[] {
542
+ const out: Record<string, string | number>[] = [];
543
+ const lines = text.split("\n");
544
+ if (lines.length < 2) return out;
545
+ const header = (lines[0] ?? "").split("\t");
546
+ for (let i = 1; i < lines.length; i++) {
547
+ const line = lines[i];
548
+ if (!line) continue;
549
+ const cells = line.split("\t");
550
+ const row: Record<string, string | number> = {};
551
+ for (let c = 0; c < header.length; c++) {
552
+ const key = header[c];
553
+ if (!key) continue;
554
+ const raw = cells[c] ?? "";
555
+ row[key] = c === numericColumn ? Number(raw) || 0 : raw.startsWith('"') ? (JSON.parse(raw) as string) : raw;
556
+ }
557
+ out.push(row);
558
+ }
559
+ return out;
560
+ }
561
+
562
+ function readDefRows(index: IndexState): DefRow[] {
563
+ return readTsv(readFileSync(index.meta.defsPath, "utf8"), 3) as unknown as DefRow[];
564
+ }
565
+
566
+ /**
567
+ * Stream one TSV file line by line, 4 MB at a time, and stop when a callback returns false.
568
+ *
569
+ * Every whole-file pass used to call `readDefRows`, which materializes a 76 MB index as one string
570
+ * and then as an array of row objects. On the engine + every-plugin scope (~1.7 M rows, ~300 MB)
571
+ * that is what died in the allocator, so the linear passes stream instead: bounded memory, and the
572
+ * file is never fully resident.
573
+ */
574
+ export function forEachTsvLine(path: string, onCells: (cells: string[]) => boolean | void): void {
575
+ const decoder = new StringDecoder("utf8");
576
+ const fd = openSync(path, "r");
577
+ const buffer = Buffer.alloc(1 << 22);
578
+ let pending = "";
579
+ let start = 0;
580
+ try {
581
+ for (;;) {
582
+ const read = readSync(fd, buffer, 0, buffer.length, null);
583
+ if (read <= 0) break;
584
+ pending += decoder.write(buffer.subarray(0, read));
585
+ let nl = pending.indexOf("\n", start);
586
+ while (nl >= 0) {
587
+ const line = pending.slice(start, nl);
588
+ start = nl + 1;
589
+ if (line.length && onCells(line.split("\t")) === false) return;
590
+ nl = pending.indexOf("\n", start);
591
+ }
592
+ if (start > (1 << 21)) {
593
+ pending = pending.slice(start);
594
+ start = 0;
595
+ }
596
+ }
597
+ pending += decoder.end();
598
+ const tail = pending.slice(start);
599
+ if (tail.length) onCells(tail.split("\t"));
600
+ } finally {
601
+ closeSync(fd);
602
+ }
603
+ }
604
+
605
+ function defRowFromCells(cells: string[]): DefRow | undefined {
606
+ if (cells.length < 7) return undefined;
607
+ return { 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] ?? "") };
608
+ }
609
+
610
+ /** Definition rows in index order (header skipped). Return false from the callback to stop early. */
611
+ function forEachDefRow(index: IndexState, onRow: (row: DefRow) => boolean | void): void {
612
+ let first = true;
613
+ forEachTsvLine(index.meta.defsPath, (cells) => {
614
+ if (first) {
615
+ first = false;
616
+ return;
617
+ }
618
+ const row = defRowFromCells(cells);
619
+ if (row) return onRow(row);
620
+ });
621
+ }
622
+
623
+ function readFileRows(index: IndexState): { file: string; module: string; kind: string; symbols: number }[] {
624
+ return readTsv(readFileSync(index.meta.filesPath, "utf8"), 3) as unknown as { file: string; module: string; kind: string; symbols: number }[];
625
+ }
626
+
627
+ /** Binary search the sorted defs for the first row whose symbol is >= needle (lowercased, colon-free). */
628
+ function lowerBound(path: string, needle: string): number {
629
+ const { offsets } = lineOffsets(path);
630
+ let lo = 1; // skip header
631
+ let hi = Math.max(1, offsets.length - 1);
632
+ while (lo < hi) {
633
+ const mid = (lo + hi) >> 1;
634
+ const start = offsets[mid] ?? 0;
635
+ const next = offsets[mid + 1] ?? start + 200;
636
+ const slice = readRange(path, start, Math.min(next, start + 200));
637
+ const field = (slice.split("\t")[0] ?? "").toLowerCase();
638
+ if (probe(field) < needle) lo = mid + 1;
639
+ else hi = mid;
640
+ }
641
+ return lo;
642
+ }
643
+
644
+ /** Rows starting at a line index, stopping at the first row whose symbol no longer matches. */
645
+ function rowsFrom(path: string, fromIndex: number, limit: number, matches: (row: DefRow) => boolean, scanCap?: number): DefRow[] {
646
+ const { offsets } = lineOffsets(path);
647
+ const size = statSync(path).size;
648
+ const out: DefRow[] = [];
649
+ // `limit` caps the answer; `scanCap` caps the work. Popular symbols have hundreds of rows (AActor:
650
+ // 283 in the Runtime index) and a cap tied to the answer size means rows past the cap are never
651
+ // read — the canonical Engine/.../GameFramework/Actor.h declaration lost to a CoreUObject one on
652
+ // path collation order alone.
653
+ const cap = scanCap ?? limit * 4;
654
+ for (let i = fromIndex, scanned = 0; i < offsets.length - 1 && out.length < limit && scanned < cap; i++, scanned++) {
655
+ const cells = readRange(path, offsets[i] ?? 0, offsets[i + 1] ?? size).split("\t");
656
+ if (cells.length < 7) continue;
657
+ 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] ?? "") };
658
+ if (!matches(row)) break;
659
+ out.push(row);
660
+ }
661
+ return out;
662
+ }
663
+
664
+ const unesc = (value: string) => (value.startsWith('"') ? (JSON.parse(value) as string) : value);
665
+ /** Lookup key: case- and colon-insensitive, so `A:Tick`, `A::Tick` and `a tick` all probe the same. */
666
+ const probe = (raw: string) => raw.toLowerCase().replace(/[:\s]/g, "");
667
+
668
+ const defPath = (index: IndexState) => index.meta.defsPath;
669
+
670
+ /** `Class` / `Class:Method` / `Class::Method` → the class part and the method part. */
671
+ export function splitQualified(symbol: string): { owner: string; member: string } {
672
+ const cleaned = symbol.trim().replace(/::/g, ":");
673
+ const index = cleaned.indexOf(":");
674
+ if (index < 0) return { owner: cleaned, member: "" };
675
+ return { owner: cleaned.slice(0, index), member: cleaned.slice(index + 1).replace(/:/g, "") };
676
+ }
677
+
678
+ /**
679
+ * Exact symbol lookup, no fuzzy guessing: qualified and case-insensitive, and a bare method name
680
+ * resolves through its owner (`Tick` + `AActor`). Prefix matching is opt-in so a typo never turns
681
+ * into a plausible-looking path.
682
+ */
683
+ export function lookupSymbol(index: IndexState, query: string, options: { limit?: number; prefix?: boolean; substring?: boolean; module?: string } = {}): LookupResult[] {
684
+ const path = defPath(index);
685
+ const limit = Math.max(1, options.limit ?? 50);
686
+ const { owner, member } = splitQualified(query);
687
+ const needle = probe(member ? `${owner}:${member}` : owner);
688
+ const out: LookupResult[] = [];
689
+ const accept = (row: DefRow, rank: number) => {
690
+ if (options.module && row.module !== options.module) return;
691
+ // A forward declaration is a pointer, not a definition: keep it visible (it is how agents find
692
+ // the type at all) but never ahead of the real declaration.
693
+ const forward = /^forward/.test(row.marker) ? 1 : 0;
694
+ out.push({ ...row, rank: rank + forward });
695
+ };
696
+
697
+ // Pass 1: exact symbol match. Rows are sorted by the probe key, so an exact family is one
698
+ // contiguous run: walk the whole run, rank forward declarations last, keep the best `limit`.
699
+ const exact = rowsFrom(path, lowerBound(path, needle), Number.MAX_SAFE_INTEGER, (row) => probe(row.symbol) === needle, Number.MAX_SAFE_INTEGER);
700
+ 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);
701
+
702
+ // Pass 2: qualified-member fallback — a method row is `Owner:Method`, so `Tick` under `AActor`
703
+ // matches when the row's owner is the requested one.
704
+ if (!out.length && member) {
705
+ const memberNeedle = probe(member);
706
+ const ownerProbe = probe(owner);
707
+ for (const row of rowsFrom(path, lowerBound(path, memberNeedle), limit, (row) => probe(row.symbol) === memberNeedle, 20000)) {
708
+ const rowOwner = splitQualified(row.symbol).owner;
709
+ if (ownerProbe && probe(rowOwner) === ownerProbe) accept(row, 2);
710
+ }
711
+ }
712
+
713
+ // Pass 3: prefix, only when asked for.
714
+ if (!out.length && options.prefix) {
715
+ for (const row of rowsFrom(path, lowerBound(path, needle), limit, (row) => probe(row.symbol).startsWith(needle), 50000)) accept(row, 1);
716
+ }
717
+
718
+ // Pass 4: UE naming convention. Agents search `GameMode`, the code says `ALpGameModeBase`, and a
719
+ // tool call rarely spells the prefix letter: one exact probe per conventional letter covers the
720
+ // whole family at index speed (7 binary searches, no scan).
721
+ if (!out.length && !member) {
722
+ for (const letter of ["a", "u", "f", "e", "s", "t", "i"]) {
723
+ const typed = letter + needle;
724
+ for (const row of rowsFrom(path, lowerBound(path, typed), limit, (row) => probe(row.symbol) === typed, 4000)) accept(row, 1);
725
+ if (out.length) break;
726
+ }
727
+ }
728
+
729
+ // Pass 5: substring, opt-in. A linear pass over the TSV (~0.3 s for the 69 MB engine index) that
730
+ // is only reached when every indexed pass missed, so the cost is paid by misses, not by hits.
731
+ if (!out.length && options.substring) {
732
+ forEachDefRow(index, (row) => {
733
+ if (!probe(row.symbol).includes(needle)) return;
734
+ accept(row, 3);
735
+ return out.length < limit;
736
+ });
737
+ }
738
+ return out.sort((a, b) => a.rank - b.rank || a.scope.localeCompare(b.scope) || a.symbol.localeCompare(b.symbol)).slice(0, limit);
739
+ }
740
+
741
+ /** Symbol names starting with a prefix — the cheap answer to "what is `Lp` in this project". */
742
+ export function listSymbols(index: IndexState, prefix: string, options: { limit?: number; kind?: string; module?: string } = {}): { symbol: string; kind: string; file: string; line: number; module: string }[] {
743
+ const path = defPath(index);
744
+ const needle = probe(prefix);
745
+ const limit = Math.max(1, options.limit ?? 100);
746
+ const out: { symbol: string; kind: string; file: string; line: number; module: string }[] = [];
747
+ for (const row of rowsFrom(path, lowerBound(path, needle), limit * 10, (row) => probe(row.symbol).startsWith(needle))) {
748
+ if (options.kind && row.kind !== options.kind) continue;
749
+ if (options.module && row.module !== options.module) continue;
750
+ out.push({ symbol: row.symbol, kind: row.kind, file: row.file, line: row.line, module: row.module });
751
+ if (out.length >= limit) break;
752
+ }
753
+ return out;
754
+ }
755
+
756
+ /** Symbols declared in one file, in file order (a linear pass over a 69 MB TSV: ~0.3 s). */
757
+ export function symbolsInFile(index: IndexState, file: string, limit: number): DefRow[] {
758
+ const target = probe(file);
759
+ const path = defPath(index);
760
+ const out: DefRow[] = [];
761
+ forEachDefRow(index, (row) => {
762
+ if (probe(row.file) === target) out.push(row);
763
+ return out.length < limit;
764
+ });
765
+ return out;
766
+ }
767
+
768
+ /* ──────────────────────────────────────────────────────────────────────── modules and files */
769
+
770
+ export interface ModuleInfo {
771
+ name: string;
772
+ scope: string;
773
+ files: number;
774
+ symbols: number;
775
+ directory: string;
776
+ publicHeaderCount: number;
777
+ sourceCount: number;
778
+ }
779
+
780
+ /** Module inventory, recovered from files.tsv; `directory` is the shallowest directory seen. */
781
+ export function listModules(index: IndexState, options: { limit?: number } = {}): ModuleInfo[] {
782
+ const rows = readFileRows(index);
783
+ const scopes = new Map<string, string>();
784
+ forEachDefRow(index, (row) => {
785
+ if (!scopes.has(row.module) && row.scope) scopes.set(row.module, row.scope);
786
+ });
787
+ const map = new Map<string, ModuleInfo>();
788
+ for (const row of rows) {
789
+ const directory = row.file.split("/").slice(0, -1).join("/");
790
+ const info = map.get(row.module) ?? { name: row.module, scope: scopes.get(row.module) ?? "", files: 0, symbols: 0, directory, publicHeaderCount: 0, sourceCount: 0 };
791
+ info.files += 1;
792
+ info.symbols += row.symbols;
793
+ if (directory.length < info.directory.length) info.directory = directory;
794
+ if (/\/public\//i.test(row.file)) info.publicHeaderCount += 1;
795
+ if (/\.(cpp|cc|c|inl|mm)$/i.test(row.file)) info.sourceCount += 1;
796
+ map.set(row.module, info);
797
+ }
798
+ return [...map.values()].sort((a, b) => b.symbols - a.symbols).slice(0, options.limit ?? 1000);
799
+ }
800
+
801
+ /** Files of a module, public headers first — "what does module X expose". */
802
+ /**
803
+ * Resolve a partial path (`LpCharacterTypes.h`, `Public/Character/LpCharacterTypes.h`) against the
804
+ * indexed files. Agents pass what they see in an error message, not an absolute path.
805
+ */
806
+ export function findFileBySuffix(index: IndexState, suffix: string, limit = 6): string[] {
807
+ const want = normalizePath(suffix).toLowerCase();
808
+ return readFileRows(index)
809
+ .filter((row) => row.file.toLowerCase().endsWith(want) || row.file.toLowerCase().includes("/" + want))
810
+ .slice(0, limit)
811
+ .map((row) => row.file);
812
+ }
813
+
814
+ export function listFiles(index: IndexState, module: string, options: { kind?: string; limit?: number } = {}): { file: string; module: string; kind: string; symbols: number }[] {
815
+ const rows = readFileRows(index);
816
+ const limit = options.limit ?? 300;
817
+ const wanted = module.toLowerCase();
818
+ return rows
819
+ .filter((row) => row.module.toLowerCase() === wanted && (!options.kind || row.kind === options.kind))
820
+ .sort((a, b) => (/\/public\//i.test(a.file) ? -1 : 1) - (/\/public\//i.test(b.file) ? -1 : 1) || a.file.localeCompare(b.file))
821
+ .slice(0, limit);
822
+ }
823
+
824
+ /**
825
+ * Real **file names** whose basename shares a token with `query` — the answer to "this header does not
826
+ * exist, what does" (§3 of the field findings). These are file names, never type names: a 0-row symbol
827
+ * lookup must not turn into a fuzzy type suggestion, so callers label them as files and say so.
828
+ *
829
+ * Tokenisation splits camelCase and separators: `MoverState` → `mover, state`; a candidate scores by
830
+ * how many of the query's tokens it contains, so `MoverTypes.h` and `MoverSimulationTypes.h` outrank
831
+ * `MoverEditor.h`.
832
+ */
833
+ export function suggestFileNames(index: IndexState, query: string, options: { limit?: number; module?: string } = {}): { file: string; module: string; score: number; tokens: string[] }[] {
834
+ const tokens = nameTokens(baseName(query).replace(/\.(h|hpp|hxx|inl|cpp|cc|c|md)$/i, ""));
835
+ if (!tokens.length) return [];
836
+ const limit = options.limit ?? 8;
837
+ const module = options.module?.toLowerCase();
838
+ const out: { file: string; module: string; score: number; tokens: string[] }[] = [];
839
+ for (const row of readFileRows(index)) {
840
+ if (module && row.module.toLowerCase() !== module) continue;
841
+ const stem = baseName(row.file).replace(/\.(h|hpp|hxx|inl|cpp|cc|c)$/i, "");
842
+ const mine = nameTokens(stem);
843
+ let score = 0;
844
+ for (const token of tokens) if (mine.includes(token)) score++;
845
+ if (!score) continue;
846
+ /* Two tie-breakers that matter when a name is invented: a row containing *every* query token is
847
+ * closer than one sharing a single generic token, and the public header of a module is the file
848
+ * an engineer is meant to include (a Private/Logging.h is not the answer to a question about a
849
+ * component). */
850
+ if (score === tokens.length) score += 2;
851
+ if (fileKindLabel(row.file) === "public-header") score += 1;
852
+ out.push({ file: row.file, module: row.module, score, tokens: mine });
853
+ }
854
+ out.sort((a, b) => b.score - a.score || baseName(a.file).length - baseName(b.file).length || a.file.localeCompare(b.file));
855
+ return out.slice(0, limit);
856
+ }
857
+
858
+ /** `BlendStackAnimInstance` → `blend, stack, anim, instance` (the vocabulary suggestions are scored with). */
859
+ export function nameTokens(value: string): string[] {
860
+ return (
861
+ value
862
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
863
+ .split(/[^A-Za-z0-9]+/)
864
+ .map((token) => token.toLowerCase())
865
+ .filter((token) => token.length > 1) as string[]
866
+ );
867
+ }
868
+
869
+ /* ──────────────────────────────────────────────────────────────────────── compile unit */
870
+
871
+ export interface ContextUnit {
872
+ file: string;
873
+ module: string;
874
+ kind: string;
875
+ defines: string[];
876
+ definesFile: string;
877
+ includePaths: string[];
878
+ standard: string;
879
+ compiler: string;
880
+ compilerArgs: string[];
881
+ forcedIncludes: string[];
882
+ includes: string[];
883
+ declares: string[];
884
+ ownerOf: { symbol: string; file: string; line: number }[];
885
+ moduleRefs: { public: string[]; private: string[]; publicInclude: string[]; privateInclude: string[]; dynamicallyLoaded: string[]; circular: string[] };
886
+ notes: string[];
887
+ }
888
+
889
+ /**
890
+ * The compile unit: what one file is compiled with and what it sees. Answers "why is this symbol
891
+ * invisible here" without opening the build graph. Include paths, defines, the C++ standard and the
892
+ * compiler come from the cached `TargetDetails` JSON; defines are read out of the `Definitions*.h`
893
+ * forced include, because TargetDetails reports `defines: []` for every module.
894
+ */
895
+ export function contextFor(index: IndexState, file: string, options: { limit?: number; queryFiles?: { targetDetails: string; moduleReferences: string } } = {}): ContextUnit | undefined {
896
+ const target = normalizePath(file);
897
+ const rows = readFileRows(index);
898
+ const hit = rows.find((row) => row.file === target) ?? rows.find((row) => row.file.toLowerCase() === target.toLowerCase());
899
+ if (!hit) return undefined;
900
+
901
+ const notes: string[] = [];
902
+ const limit = options.limit ?? 200;
903
+ const unit: ContextUnit = {
904
+ file: hit.file,
905
+ module: hit.module,
906
+ kind: hit.kind,
907
+ defines: [],
908
+ definesFile: "",
909
+ includePaths: [],
910
+ standard: "",
911
+ compiler: "",
912
+ compilerArgs: [],
913
+ forcedIncludes: [],
914
+ includes: [],
915
+ declares: [],
916
+ ownerOf: [],
917
+ moduleRefs: { public: [], private: [], publicInclude: [], privateInclude: [], dynamicallyLoaded: [], circular: [] },
918
+ notes,
919
+ };
920
+
921
+ if (index.meta.includesPath && existsSync(index.meta.includesPath)) {
922
+ for (const line of readFileSync(index.meta.includesPath, "utf8").split("\n").slice(1)) {
923
+ const cells = line.split("\t");
924
+ if (cells[0] && unesc(cells[0]) === hit.file) {
925
+ unit.includes = (cells[2] ?? "").split(",").filter(Boolean).slice(0, limit);
926
+ break;
927
+ }
928
+ }
929
+ } else {
930
+ notes.push("includes.tsv absent (definitions index) — `unreal_index build --full` adds per-file include sets");
931
+ }
932
+
933
+ const declared = symbolsInFile(index, hit.file, limit);
934
+ unit.declares = declared.map((row) => row.symbol);
935
+ for (const row of declared.filter((row) => row.kind === "function")) {
936
+ const owner = splitQualified(row.symbol).owner;
937
+ if (!owner) continue;
938
+ const [ownerRow] = lookupSymbol(index, owner, { limit: 1 });
939
+ if (ownerRow) unit.ownerOf.push({ symbol: owner, file: ownerRow.file, line: ownerRow.line });
940
+ }
941
+
942
+ const queryFile = options.queryFiles?.targetDetails ?? index.meta.queryFiles?.targetDetails;
943
+ if (queryFile && existsSync(queryFile)) {
944
+ const settings = parseTargetDetails(readQueryJson(queryFile)).settings.get(hit.module);
945
+ if (settings) applySettings(unit, settings, notes);
946
+ else notes.push(`TargetDetails has no compile settings for module ${hit.module} (target identity mismatch, or a module UBT did not build)`);
947
+ } else {
948
+ notes.push("no cached TargetDetails JSON — include paths, defines and the compiler need `unreal_index build --full` (it runs UnrealBuildTool Query)");
949
+ }
950
+
951
+ const refsFile = options.queryFiles?.moduleReferences ?? index.meta.queryFiles?.moduleReferences;
952
+ if (refsFile && existsSync(refsFile)) {
953
+ const refs = parseModuleReferences(readQueryJson(refsFile)).get(hit.module);
954
+ if (refs) unit.moduleRefs = { public: refs.public, private: refs.private, publicInclude: refs.publicInclude, privateInclude: refs.privateInclude, dynamicallyLoaded: refs.dynamicallyLoaded, circular: refs.circular };
955
+ }
956
+ return unit;
957
+ }
958
+
959
+ function applySettings(unit: ContextUnit, settings: UbtModuleSettings, notes: string[]): void {
960
+ unit.includePaths = settings.includePaths;
961
+ unit.standard = settings.standard;
962
+ unit.compiler = settings.compilerPath;
963
+ unit.compilerArgs = [...settings.compilerArgs, ...settings.compilerAdditionalArgs.cpp];
964
+ unit.forcedIncludes = settings.forcedIncludes;
965
+ unit.definesFile = settings.definesFile;
966
+ if (settings.defines.length) unit.defines = settings.defines;
967
+ if (settings.definesFile && existsSync(settings.definesFile)) {
968
+ unit.defines = readDefines(settings.definesFile);
969
+ notes.push(`defines read from the forced include ${settings.definesFile} (TargetDetails 'defines' is empty for every module)`);
970
+ } else if (!settings.defines.length) {
971
+ notes.push("defines unavailable: TargetDetails reports none and the Definitions header is not on disk (build the target once to generate it)");
972
+ }
973
+ }
974
+
975
+ /** `#define X` / `#define X Y` from a `Definitions*.h` header. */
976
+ export function readDefines(path: string): string[] {
977
+ const out: string[] = [];
978
+ let text: string;
979
+ try {
980
+ text = readFileSync(path, "utf8");
981
+ } catch {
982
+ return out;
983
+ }
984
+ for (const line of text.split(/\r?\n/)) {
985
+ const match = /^\s*#\s*define\s+([A-Za-z_][A-Za-z0-9_]*)\b(.*)$/.exec(line);
986
+ if (!match?.[1]) continue;
987
+ const value = (match[2] ?? "").trim();
988
+ out.push(value === "1" ? match[1] : value ? `${match[1]}=${value}` : match[1]);
989
+ }
990
+ return out;
991
+ }
992
+
993
+ /** Module dependency lists from cached Query data, without a symbol index (definitions-only mode). */
994
+ export function moduleRefsFromQuery(queryFiles: { moduleReferences: string }, module: string): UbtModuleRefs | undefined {
995
+ if (!existsSync(queryFiles.moduleReferences)) return undefined;
996
+ return parseModuleReferences(readQueryJson(queryFiles.moduleReferences)).get(module);
997
+ }
998
+
999
+ /* ──────────────────────────────────────────────────────────────────────── registry */
1000
+
1001
+ /** Index key: mode + sorted roots (full adds the target identity, because include sets are target-specific). */
1002
+ export function indexKeyFor(input: { mode: "definitions" | "full"; roots: string[]; scope?: IndexScope; target?: string; platform?: string; configuration?: string }): string {
1003
+ const roots = input.roots.map((root) => normalizePath(root).toLowerCase()).sort();
1004
+ const parts = [input.mode, input.scope ?? "", ...roots];
1005
+ if (input.mode === "full") parts.push(input.target ?? "", input.platform ?? "", input.configuration ?? "");
1006
+ const fingerprint = shortHash(parts.join("|"));
1007
+ const label = input.scope === "Engine" ? "engine" : (input.roots[0] ?? roots[0] ?? "index").split("/").filter(Boolean).pop() ?? "index";
1008
+ return `idx-${label.replace(/[^a-z0-9]/gi, "")}-${input.mode === "full" ? "full" : "defs"}-${fingerprint}`;
1009
+ }
1010
+
1011
+ export function indexesRoot(): string {
1012
+ return normalizePath(joinPath(cacheDir(), "indexes"));
1013
+ }
1014
+
1015
+ export function indexDir(key: string): string {
1016
+ const dir = normalizePath(joinPath(indexesRoot(), key));
1017
+ mkdirSync(dir, { recursive: true });
1018
+ return dir;
1019
+ }
1020
+
1021
+ /** Every index on disk, newest first, with health (status, `--rebuild`, and the auto path). */
1022
+ export function listIndexes(): IndexState[] {
1023
+ const root = indexesRoot();
1024
+ if (!existsSync(root)) return [];
1025
+ const out: IndexState[] = [];
1026
+ for (const key of readdirSync(root)) {
1027
+ const meta = readJson<IndexMeta>(joinPath(root, key, "meta.json"));
1028
+ if (!meta || !meta.defsPath) continue;
1029
+ const issues: string[] = [];
1030
+ if (meta.version !== INDEX_VERSION) issues.push(`format v${meta.version}, current v${INDEX_VERSION}`);
1031
+ if (!existsSync(meta.defsPath)) issues.push("defs.tsv missing");
1032
+ if (meta.mode === "full" && (!meta.includesPath || !existsSync(meta.includesPath))) issues.push("includes.tsv missing");
1033
+ for (const r of meta.roots ?? []) if (!existsSync(r)) issues.push(`root no longer present: ${r}`);
1034
+ let ageMs = 0;
1035
+ try {
1036
+ ageMs = Date.now() - statSync(joinPath(root, key)).mtimeMs;
1037
+ } catch {
1038
+ /* unreadable directory: report age 0 */
1039
+ }
1040
+ 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 }) });
1041
+ }
1042
+ return out.sort((a, b) => b.ageMs - a.ageMs);
1043
+ }
1044
+
1045
+ export function loadIndex(key: string): IndexState | undefined {
1046
+ const dir = normalizePath(joinPath(indexesRoot(), key));
1047
+ const meta = readJson<IndexMeta>(joinPath(dir, "meta.json"));
1048
+ if (!meta) return undefined;
1049
+ let ageMs = 0;
1050
+ try {
1051
+ ageMs = Date.now() - statSync(dir).mtimeMs;
1052
+ } catch {
1053
+ /* caller reports the missing directory */
1054
+ }
1055
+ 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 }) };
1056
+ }
1057
+
1058
+ /** The index a query should use: explicit key, else the newest healthy index matching the filter. */
1059
+ /** Same root set, same index. Path-compare is case-insensitive because Windows is. */
1060
+ export const rootsSignature = (roots: string[]) => roots.map((root) => normalizePath(root).toLowerCase()).sort().join("|");
1061
+
1062
+ export function pickIndex(filter: { key?: string; mode?: "definitions" | "full"; projectRoot?: string; engineRoot?: string; scope?: IndexScope; roots?: string[] }): { index?: IndexState; candidates: IndexState[]; notes: string[] } {
1063
+ const notes: string[] = [];
1064
+ const all = listIndexes();
1065
+ if (filter.key) {
1066
+ const found = all.find((state) => state.key === filter.key);
1067
+ if (found) return { index: found, candidates: all, notes };
1068
+ notes.push(`no index named ${filter.key}`);
1069
+ return { candidates: all, notes };
1070
+ }
1071
+ let pool = all.filter((state) => state.healthy);
1072
+ if (filter.mode) pool = pool.filter((state) => state.meta.mode === filter.mode);
1073
+ if (filter.projectRoot) pool = pool.filter((state) => normalizePath(state.meta.projectRoot ?? "").toLowerCase() === normalizePath(filter.projectRoot ?? "").toLowerCase());
1074
+ if (filter.scope) pool = pool.filter((state) => state.scope === filter.scope);
1075
+ // Scope alone is not a coverage claim: an index built over Engine/Source/Core answers a
1076
+ // Runtime-wide query with a partial list, and a partial list is worse than no list.
1077
+ const want = filter.roots ? rootsSignature(filter.roots) : "";
1078
+ if (want) {
1079
+ pool = pool.filter((state) => rootsSignature(state.meta.roots ?? []) === want);
1080
+ if (!pool.length && all.some((state) => state.healthy && (!filter.mode || state.meta.mode === filter.mode) && (!filter.scope || state.scope === filter.scope))) {
1081
+ 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`);
1082
+ }
1083
+ }
1084
+ const engineRoot = filter.engineRoot ? normalizePath(filter.engineRoot).toLowerCase() : "";
1085
+ 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));
1086
+ if (!pool.length) notes.push(all.length ? `${all.length} index/indices exist, none healthy and matching this scope` : "no symbol index built yet");
1087
+ return { index: pool[0], candidates: pool, notes };
1088
+ }
1089
+
1090
+ /* ────────────────────────────────────────────────────────── header (path-only) index */
1091
+
1092
+ /**
1093
+ * A second, much cheaper index: **file paths only**, never file contents.
1094
+ *
1095
+ * It exists because the highest-value question in a UE session is "does this header/type exist in
1096
+ * *this engine version*?", and a definitions index cannot answer a no — absence from a partially
1097
+ * scanned root set is not absence from the engine. Walking the tree and recording paths is a few
1098
+ * seconds (measured: 52,568 plugin sources + 18,681 Runtime files in ~1 s of walking, no reads), so
1099
+ * "exists / does not exist, here is the exact path" becomes an answer the tools can actually give.
1100
+ *
1101
+ * Built only by an explicit `unreal_index action: "headers"` — never implicitly by a lookup, and
1102
+ * never as a side effect of a symbol query.
1103
+ */
1104
+ export const HEADER_VERSION = 1;
1105
+
1106
+ /** Header extensions the path index records (the things an `#include` line can name). */
1107
+ const HEADER_SCAN_EXT = [".h", ".hpp", ".hxx", ".inl"];
1108
+
1109
+ export interface HeaderIndexMeta {
1110
+ version: number;
1111
+ key: string;
1112
+ generatedAt: string;
1113
+ roots: string[];
1114
+ engineRoot?: string;
1115
+ projectRoot?: string;
1116
+ areas?: string[];
1117
+ files: number;
1118
+ /** Root provenance by kind, same vocabulary as `IndexMeta.rootKinds`. */
1119
+ rootKinds?: Record<string, number>;
1120
+ path: string;
1121
+ sizeBytes: number;
1122
+ warnings: string[];
1123
+ }
1124
+
1125
+ export interface HeaderIndex {
1126
+ key: string;
1127
+ dir: string;
1128
+ meta: HeaderIndexMeta;
1129
+ ageMs: number;
1130
+ healthy: boolean;
1131
+ issues: string[];
1132
+ }
1133
+
1134
+ export function headersRoot(): string {
1135
+ return normalizePath(joinPath(cacheDir(), "headers"));
1136
+ }
1137
+
1138
+ /** Path-only index key: the root set (engine identity is part of the roots, so it is implied). */
1139
+ export function headerKeyFor(input: { roots: string[]; label?: string }): string {
1140
+ const roots = input.roots.map((root) => normalizePath(root).toLowerCase()).sort();
1141
+ const fingerprint = shortHash(["headers", ...roots].join("|"));
1142
+ const label = (input.label ?? roots[0] ?? "index").split("/").filter(Boolean).pop() ?? "index";
1143
+ return `hdr-${label.replace(/[^a-z0-9]/gi, "")}-${fingerprint}`;
1144
+ }
1145
+
1146
+ export async function buildHeaderIndex(input: {
1147
+ key?: string;
1148
+ roots: ScanRoot[];
1149
+ engineRoot?: string;
1150
+ projectRoot?: string;
1151
+ areas?: string[];
1152
+ concurrency?: number;
1153
+ }): Promise<{ key: string; dir: string; files: number; durationMs: number; meta: HeaderIndexMeta; built: boolean }> {
1154
+ const started = Date.now();
1155
+ const dirs = input.roots.map((root) => normalizePath(root.dir));
1156
+ const key = input.key ?? headerKeyFor({ roots: dirs });
1157
+ const dir = normalizePath(joinPath(headersRoot(), key));
1158
+ mkdirSync(dir, { recursive: true });
1159
+ const warnings: string[] = [];
1160
+
1161
+ const discovered = await collectSourceFiles(input.roots, { concurrency: input.concurrency });
1162
+ const rootKinds: Record<string, number> = {};
1163
+ for (const entry of summarizeRootKinds(dirs, { projectRoot: input.projectRoot, engineRoot: input.engineRoot })) {
1164
+ rootKinds[entry.kind] = entry.count;
1165
+ }
1166
+
1167
+ const seen = new Set<string>();
1168
+ const rows: string[] = [];
1169
+ for (const entry of discovered) {
1170
+ const lower = entry.abs.toLowerCase();
1171
+ if (!HEADER_SCAN_EXT.some((ext) => lower.endsWith(ext))) continue;
1172
+ if (seen.has(lower)) continue;
1173
+ seen.add(lower);
1174
+ const module = entry.root.module ?? moduleFromBuildCs(entry.abs);
1175
+ rows.push(`${esc(entry.abs)}\t${esc(module)}\t${fileKindLabel(entry.abs)}`);
1176
+ }
1177
+ rows.sort((a, b) => a.localeCompare(b));
1178
+
1179
+ const tsv = `file\tmodule\tkind\n${rows.join("\n")}\n`;
1180
+ const path = normalizePath(joinPath(dir, "headers.tsv"));
1181
+ writeTextAtomic(path, tsv);
1182
+
1183
+ const meta: HeaderIndexMeta = {
1184
+ version: HEADER_VERSION,
1185
+ key,
1186
+ generatedAt: new Date().toISOString(),
1187
+ roots: dirs,
1188
+ engineRoot: input.engineRoot ? normalizePath(input.engineRoot) : undefined,
1189
+ projectRoot: input.projectRoot ? normalizePath(input.projectRoot) : undefined,
1190
+ areas: input.areas,
1191
+ files: rows.length,
1192
+ rootKinds,
1193
+ path,
1194
+ sizeBytes: Buffer.byteLength(tsv, "utf8"),
1195
+ warnings,
1196
+ };
1197
+ writeJsonAtomic(joinPath(dir, "meta.json"), meta);
1198
+ return { key, dir, files: rows.length, durationMs: Date.now() - started, meta, built: true };
1199
+ }
1200
+
1201
+ export function listHeaderIndexes(): HeaderIndex[] {
1202
+ const root = headersRoot();
1203
+ if (!existsSync(root)) return [];
1204
+ const out: HeaderIndex[] = [];
1205
+ for (const key of readdirSync(root)) {
1206
+ const meta = readJson<HeaderIndexMeta>(joinPath(root, key, "meta.json"));
1207
+ if (!meta || !meta.path) continue;
1208
+ const issues: string[] = [];
1209
+ if (meta.version !== HEADER_VERSION) issues.push(`format v${meta.version}, current v${HEADER_VERSION}`);
1210
+ if (!existsSync(meta.path)) issues.push("headers.tsv missing");
1211
+ for (const r of meta.roots ?? []) if (!existsSync(r)) issues.push(`root no longer present: ${r}`);
1212
+ let ageMs = 0;
1213
+ try {
1214
+ ageMs = Date.now() - statSync(joinPath(root, key)).mtimeMs;
1215
+ } catch {
1216
+ /* unreadable directory: report age 0 */
1217
+ }
1218
+ out.push({ key, dir: normalizePath(joinPath(root, key)), meta, ageMs, healthy: issues.length === 0, issues });
1219
+ }
1220
+ return out.sort((a, b) => b.ageMs - a.ageMs);
1221
+ }
1222
+
1223
+ export function loadHeaderIndex(key: string): HeaderIndex | undefined {
1224
+ const dir = normalizePath(joinPath(headersRoot(), key));
1225
+ const meta = readJson<HeaderIndexMeta>(joinPath(dir, "meta.json"));
1226
+ if (!meta) return undefined;
1227
+ let ageMs = 0;
1228
+ try {
1229
+ ageMs = Date.now() - statSync(dir).mtimeMs;
1230
+ } catch {
1231
+ /* caller reports the missing directory */
1232
+ }
1233
+ return { key, dir, meta, ageMs, healthy: meta.version === HEADER_VERSION && existsSync(meta.path), issues: meta.version === HEADER_VERSION ? [] : [`format v${meta.version}, current v${HEADER_VERSION}`] };
1234
+ }
1235
+
1236
+ /** The newest healthy path index over exactly these roots. Never builds anything. */
1237
+ export function pickHeaderIndex(roots: string[]): HeaderIndex | undefined {
1238
+ const want = rootsSignature(roots);
1239
+ return listHeaderIndexes().find((state) => state.healthy && rootsSignature(state.meta.roots ?? []) === want);
1240
+ }
1241
+
1242
+ export interface HeaderRow {
1243
+ file: string;
1244
+ module: string;
1245
+ kind: string;
1246
+ /** `exact` = that file name exists; `suffix` = a path suffix match; `token` = shares name tokens. */
1247
+ match: "exact" | "suffix" | "token";
1248
+ }
1249
+
1250
+ function readHeaderRows(index: HeaderIndex): { file: string; module: string; kind: string }[] {
1251
+ const text = readFileSync(index.meta.path, "utf8");
1252
+ return readTsv(text) as unknown as { file: string; module: string; kind: string }[];
1253
+ }
1254
+
1255
+ /**
1256
+ * "Does this header exist?" — exact file-name match first, then path suffix, then name-token overlap.
1257
+ * Token hits are labelled `token`: they are *related files*, not the file asked for, and callers must
1258
+ * present them that way (an invented name must not be quietly reinterpreted as a real one).
1259
+ */
1260
+ export function lookupHeaderPaths(index: HeaderIndex, name: string, options: { limit?: number } = {}): HeaderRow[] {
1261
+ const raw = normalizePath(name).trim();
1262
+ const leafRaw = baseName(raw).replace(/\.(h|hpp|hxx|inl)$/i, "");
1263
+ const leaf = baseName(raw).toLowerCase();
1264
+ const stem = leaf.replace(/\.(h|hpp|hxx|inl)$/i, "");
1265
+ /* Tokenise the cased name: `PoseSearchResultTypes` → pose, search, result, types. A lowered stem
1266
+ * would tokenise as one long token and match nothing. */
1267
+ const queryTokens = nameTokens(leafRaw);
1268
+ const limit = options.limit ?? 12;
1269
+ const out: HeaderRow[] = [];
1270
+ for (const row of readHeaderRows(index)) {
1271
+ const rowLeaf = baseName(row.file).toLowerCase();
1272
+ const rowLower = row.file.toLowerCase();
1273
+ let match: HeaderRow["match"] | undefined;
1274
+ if (rowLeaf === leaf || rowLeaf === `${stem}.h`) match = "exact";
1275
+ else if (raw.includes("/") && rowLower.endsWith(leaf)) match = "suffix";
1276
+ else if (queryTokens.length > 1) {
1277
+ const rowTokens = nameTokens(rowLeaf.replace(/\.(h|hpp|hxx|inl)$/i, ""));
1278
+ let hits = 0;
1279
+ for (const token of queryTokens) if (rowTokens.includes(token)) hits++;
1280
+ if (hits === queryTokens.length) match = "token";
1281
+ }
1282
+ if (!match) continue;
1283
+ out.push({ file: row.file, module: row.module, kind: row.kind, match });
1284
+ }
1285
+ const order = { exact: 0, suffix: 1, token: 2 } as const;
1286
+ out.sort((a, b) => order[a.match] - order[b.match] || a.file.localeCompare(b.file));
1287
+ return out.slice(0, limit);
1288
+ }
1289
+
1290
+ /** Real header names related to `name` (for the "this header does not exist, here is what does" answer). */
1291
+ export function suggestHeaderPaths(index: HeaderIndex, name: string, options: { limit?: number } = {}): HeaderRow[] {
1292
+ const stem = baseName(name).replace(/\.(h|hpp|hxx|inl)$/i, "");
1293
+ const queryTokens = nameTokens(stem);
1294
+ if (!queryTokens.length) return [];
1295
+ const limit = options.limit ?? 8;
1296
+ const out: { row: HeaderRow; score: number }[] = [];
1297
+ for (const row of readHeaderRows(index)) {
1298
+ const rowTokens = nameTokens(baseName(row.file).replace(/\.(h|hpp|hxx|inl)$/i, ""));
1299
+ let score = 0;
1300
+ for (const token of queryTokens) if (rowTokens.includes(token)) score++;
1301
+ if (!score) continue;
1302
+ if (score === queryTokens.length) score += 2;
1303
+ if (row.kind === "public-header") score += 1;
1304
+ out.push({ row: { ...row, match: "token" }, score });
1305
+ }
1306
+ out.sort((a, b) => b.score - a.score || baseName(a.row.file).length - baseName(b.row.file).length || a.row.file.localeCompare(b.row.file));
1307
+ return out.slice(0, limit).map((entry) => entry.row);
1308
+ }
1309
+
1310
+ /* ──────────────────────────────────────────────────────────────────────── helpers */
1311
+
1312
+ 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");
1313
+
1314
+ function readJson<T>(path: string): T | undefined {
1315
+ try {
1316
+ return JSON.parse(readFileSync(path, "utf8")) as T;
1317
+ } catch {
1318
+ return undefined;
1319
+ }
1320
+ }
1321
+
1322
+ /** Write-then-rename, so an interrupted build never leaves a half-written TSV behind. */
1323
+ function writeTextAtomic(path: string, text: string): void {
1324
+ const tmp = `${path}.tmp`;
1325
+ writeFileSync(tmp, text, "utf8");
1326
+ if (existsSync(path)) {
1327
+ try {
1328
+ renameSync(tmp, path);
1329
+ } catch {
1330
+ writeFileSync(path, text, "utf8");
1331
+ try {
1332
+ renameSync(tmp, path);
1333
+ } catch {
1334
+ /* Windows locking (an open reader): the first writeFileSync already landed */
1335
+ }
1336
+ }
1337
+ return;
1338
+ }
1339
+ renameSync(tmp, path);
1340
+ }
1341
+
1342
+ function writeJsonAtomic(path: string, value: unknown): void {
1343
+ writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
1344
+ }
1345
+
1346
+ /**
1347
+ * Module attribution without Query data: the nearest `*.Build.cs` above the file.
1348
+ *
1349
+ * The **module file name is the module name** — UBT names a module after its `X.Build.cs`, not after
1350
+ * the directory. `Engine/Plugins/Animation/PoseSearch/Source/Runtime/PoseSearch.Build.cs` is module
1351
+ * `PoseSearch` in a directory leaf-named `Runtime`, and `Runtime` is a leaf reused by 10 other modules
1352
+ * in the 5.8 tree (`Source` 20x, `Runtime` 10x, `Editor` 9x, `Module` 5x out of 1,079). Naming the
1353
+ * leaf is what made `unreal_symbol module: "PoseSearch"` return 0 rows in a definitions index while
1354
+ * `unreal_context module: "PoseSearch"` (UBT data) returned the truth.
1355
+ *
1356
+ * `moduleNames` (from a `.uplugin`'s Modules[].Name) breaks ties when one directory owns several
1357
+ * `*.Build.cs` files: prefer the declared name, then the leaf, then the shortest.
1358
+ */
1359
+ export function moduleFromBuildCs(file: string, moduleNames?: string[]): string {
1360
+ const parts = normalizePath(file).split("/");
1361
+ for (let i = parts.length - 2; i >= 0; i--) {
1362
+ const dir = parts.slice(0, i + 1).join("/");
1363
+ const leaf = parts[i] ?? "";
1364
+ const names = buildCsNames(dir);
1365
+ if (!names.length) continue;
1366
+ return pickModuleName(names, leaf, moduleNames);
1367
+ }
1368
+ const marker = parts.indexOf("Source");
1369
+ const candidate = marker >= 0 ? parts[marker + 1] : undefined;
1370
+ if (candidate) return candidate;
1371
+ return parts[parts.length - 2] ?? "root";
1372
+ }
1373
+
1374
+ /** Module names owned by `dir`: basenames of its `*.Build.cs` files (empty when it owns none). */
1375
+ export function buildCsNames(dir: string): string[] {
1376
+ const here = normalizePath(dir);
1377
+ let names: string[];
1378
+ try {
1379
+ names = readdirSync(here);
1380
+ } catch {
1381
+ return [];
1382
+ }
1383
+ return names.filter((entry) => entry.endsWith(".Build.cs")).map((entry) => entry.slice(0, -".Build.cs".length));
1384
+ }
1385
+
1386
+ /** Choose the module name a directory's `*.Build.cs` files imply (see `moduleFromBuildCs`). */
1387
+ export function pickModuleName(names: string[], leaf: string, moduleNames?: string[]): string {
1388
+ if (names.length === 0) return leaf;
1389
+ if (names.length === 1) return names[0]!;
1390
+ if (moduleNames?.length) {
1391
+ const declared = names.filter((name) => moduleNames.includes(name)).sort((a, b) => a.length - b.length);
1392
+ if (declared.length) return declared[0]!;
1393
+ }
1394
+ if (names.includes(leaf)) return leaf;
1395
+ return [...names].sort((a, b) => a.length - b.length || a.localeCompare(b))[0]!;
1396
+ }
1397
+
1398
+ /**
1399
+ * Scan roots for an index build. A "root" is one module directory (the directory holding a
1400
+ * `*.Build.cs`), because that is the unit UBT compiles and the unit a symbol's module belongs to.
1401
+ *
1402
+ * Engine roots are limited to the areas that carry game-facing C++ (`Runtime`, `Editor`,
1403
+ * `Developer`) by default: `Engine/Source/Programs` (UnrealLightmass, ShaderCompileTool, ...) is
1404
+ * tens of thousands of files of build-tool code that a gameplay symbol lookup will never want.
1405
+ */
1406
+ export interface RootOptions {
1407
+ projectRoot?: string;
1408
+ engineRoot?: string;
1409
+ /** Plugin directories (absolute), e.g. from `PluginSummary.dir`. */
1410
+ pluginDirs?: string[];
1411
+ includeEngine?: boolean;
1412
+ /**
1413
+ * What to scan on the engine side. Two shapes, both rooted at the engine install:
1414
+ * - an `Engine/Source` area: `"Runtime"`, `"Editor"`, `"Runtime/Core"`, `"Runtime/CoreUObject"`;
1415
+ * - an engine-plugin area, prefixed `Plugins`: `"Plugins"` (every plugin under `Engine/Plugins`,
1416
+ * ~2.8x the Runtime scan) or a subtree `"Plugins/Animation/PhysicsControl"`.
1417
+ * Default `DEFAULT_ENGINE_AREAS` (`["Runtime"]`).
1418
+ */
1419
+ engineAreas?: string[];
1420
+ /** Declared module names per plugin root dir (from its `.uplugin`), used to name modules correctly. */
1421
+ pluginModuleNames?: Record<string, string[]>;
1422
+ fs?: FsAdapter;
1423
+ }
1424
+
1425
+ /** Module directories (each contains a `*.Build.cs`) directly under `dir`. */
1426
+ /**
1427
+ * Module roots under `dir`: each immediate child that contains a `*.Build.cs`.
1428
+ * If `dir` itself is a module directory (`Engine/Source/Runtime/CoreUObject`) it is the root.
1429
+ */
1430
+ const SKIP_DIR_NAMES = new Set(["Intermediate", "Binaries", "Saved", ".git", ".vs", "ThirdParty", "Programs", "Shaders", "Config", "Content", "Build"]);
1431
+
1432
+ /**
1433
+ * A module directory is any directory under `dir` that owns a `*.Build.cs`, at any depth. UE puts
1434
+ * modules at `Runtime/Core`, `Runtime/Engine/Classes`... and areas are addressable as
1435
+ * `engineAreas: ["Runtime/Core"]`, so the search must recurse (depth-capped, build-system dirs skipped).
1436
+ */
1437
+ function moduleDirsUnder(dir: string, scope: ScanRoot["scope"], fs: FsAdapter, maxDepth = 3, depth = 0, moduleNames?: string[]): ScanRoot[] {
1438
+ const roots: ScanRoot[] = [];
1439
+ const here = normalizePath(dir);
1440
+ const names = buildCsNames(here);
1441
+ if (names.length) {
1442
+ roots.push({ dir: here, scope, module: pickModuleName(names, baseName(here), moduleNames) });
1443
+ return roots; // a module's own tree never contains another module
1444
+ }
1445
+ if (depth >= maxDepth) return roots;
1446
+ for (const child of fs.listDir(here)) {
1447
+ const abs = joinPath(here, child);
1448
+ if (!fs.isDirectory(abs)) continue;
1449
+ if (SKIP_DIR_NAMES.has(child)) continue;
1450
+ roots.push(...moduleDirsUnder(abs, scope, fs, maxDepth, depth + 1, moduleNames));
1451
+ }
1452
+ return roots;
1453
+ }
1454
+
1455
+ export function assembleRoots(options: RootOptions): { roots: ScanRoot[]; notes: string[] } {
1456
+ const fs = options.fs ?? realFs;
1457
+ const notes: string[] = [];
1458
+ const roots: ScanRoot[] = [];
1459
+
1460
+ if (options.projectRoot) {
1461
+ const projectRoot = normalizePath(options.projectRoot);
1462
+ const found = moduleDirsUnder(joinPath(projectRoot, "Source"), "Project", fs);
1463
+ roots.push(...found);
1464
+ notes.push(found.length ? `project: ${found.length} module root(s) under ${projectRoot}/Source` : `no module roots under ${projectRoot}/Source`);
1465
+ }
1466
+
1467
+ for (const pluginDir of options.pluginDirs ?? []) {
1468
+ const abs = normalizePath(pluginDir);
1469
+ const moduleNames = options.pluginModuleNames?.[abs];
1470
+ const found = moduleDirsUnder(joinPath(abs, "Source"), "Plugin", fs, 3, 0, moduleNames);
1471
+ roots.push(...found);
1472
+ if (!found.length) notes.push(`plugin ${baseName(abs)}: no C++ module roots`);
1473
+ }
1474
+
1475
+ if (options.includeEngine && options.engineRoot) {
1476
+ const engineRoot = normalizePath(options.engineRoot);
1477
+ const areas = options.engineAreas?.length ? options.engineAreas : DEFAULT_ENGINE_AREAS;
1478
+ for (const area of areas) {
1479
+ // "Plugins/..." addresses Engine/Plugins; anything else is an Engine/Source area.
1480
+ const pluginArea = /^plugins(\/|$)/i.test(area);
1481
+ const found = moduleDirsUnder(joinPath(engineRoot, pluginArea ? "Engine" : "Engine/Source", area), "Engine", fs, pluginArea ? 6 : 4);
1482
+ roots.push(...found);
1483
+ notes.push(`engine ${area}: ${found.length} module root(s)`);
1484
+ }
1485
+ }
1486
+
1487
+ return { roots, notes };
1488
+ }
1489
+
1490
+ export type { IncludeRef, ScanRoot, SymbolDef };