@aeonsparx/pi-unreal 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,19 +23,32 @@
23
23
  * for the project, one per target) and the answer depends on which one was read.
24
24
  */
25
25
 
26
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
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";
27
29
  import { cacheDir } from "./cache.ts";
28
30
  import { normalizePath, joinPath, shortHash, baseName, realFs, type FsAdapter } from "./util.ts";
29
31
  import { collectSourceFiles, scanText, type IncludeRef, type ScanRoot, type SymbolDef } from "./scan.ts";
30
32
  import { parseModuleReferences, parseTargetDetails, readQueryJson, type UbtModuleRefs, type UbtModuleSettings } from "./ubt.ts";
31
33
 
32
- export const INDEX_VERSION = 5;
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;
33
40
 
34
41
  /**
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`.
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).
39
52
  */
40
53
  export const DEFAULT_ENGINE_AREAS = ["Runtime"];
41
54
 
@@ -61,6 +74,10 @@ export interface IndexMeta {
61
74
  configuration?: string;
62
75
  counts: { files: number; symbols: number; includes: number; modules: number };
63
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[];
64
81
  warnings: string[];
65
82
  sizeBytes: number;
66
83
  defsPath: string;
@@ -98,6 +115,44 @@ export interface IndexBuild {
98
115
 
99
116
  export type DefRow = { symbol: string; kind: string; file: string; line: number; module: string; scope: string; marker: string };
100
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
+
101
156
  /**
102
157
  * Provenance of a set of scan roots. Plugin module directories inside an installed engine tree count as
103
158
  * project-side (`.../Engine/Plugins/...`), so a project + plugins index classifies as `Project` and an
@@ -156,6 +211,109 @@ function byProbe(a: DefRow, b: DefRow): number {
156
211
  return a.line - b.line;
157
212
  }
158
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
+
159
317
  export async function buildIndex(input: {
160
318
  key: string;
161
319
  mode: "definitions" | "full";
@@ -168,9 +326,16 @@ export async function buildIndex(input: {
168
326
  platform?: string;
169
327
  configuration?: string;
170
328
  queryFiles?: { targetDetails: string; moduleReferences: string };
329
+ /** Recorded verbatim in `meta.areas` so status can state what an engine index scanned. */
330
+ areas?: string[];
171
331
  /** Where the ScanRoot provenance comes from; `deriveScope(roots)` when omitted. */
172
332
  scope?: IndexScope;
173
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;
174
339
  }): Promise<IndexBuild> {
175
340
  const started = Date.now();
176
341
  const dir = indexDir(input.key);
@@ -187,11 +352,41 @@ export async function buildIndex(input: {
187
352
  return moduleFromBuildCs(file);
188
353
  };
189
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
+
190
361
  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[]>();
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;
195
390
  const modules = new Set<string>();
196
391
  const defsByKind: Record<string, number> = {};
197
392
 
@@ -206,19 +401,26 @@ export async function buildIndex(input: {
206
401
  const { defs, includes } = scanText(text, entry.abs, module, entry.root.scope);
207
402
  modules.add(module);
208
403
  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 });
404
+ batch.push({ symbol: def.symbol, kind: def.kind, file: def.file, line: def.line, module: def.module, scope: def.scope, marker: def.marker });
210
405
  defsByKind[def.kind] = (defsByKind[def.kind] ?? 0) + 1;
211
406
  }
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(",")}`);
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
+ }
214
414
  }
415
+ flushBatch();
416
+ filesOut.close();
417
+ includesOut?.close();
215
418
 
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`);
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 });
222
424
 
223
425
  const meta: IndexMeta = {
224
426
  version: INDEX_VERSION,
@@ -235,10 +437,12 @@ export async function buildIndex(input: {
235
437
  target: input.target,
236
438
  platform: input.platform,
237
439
  configuration: input.configuration,
238
- counts: { files: fileRows.length, symbols: rows.length, includes: includeRows.length, modules: modules.size },
440
+ counts: { files: fileTotal, symbols: rowTotal, includes: includeTotal, modules: modules.size },
239
441
  defsByKind,
442
+ rootKinds,
443
+ areas: input.areas,
240
444
  warnings,
241
- sizeBytes: Buffer.byteLength(defsTsv, "utf8"),
445
+ sizeBytes: defsOut.bytes,
242
446
  defsPath: normalizePath(joinPath(dir, "defs.tsv")),
243
447
  filesPath: normalizePath(joinPath(dir, "files.tsv")),
244
448
  includesPath: input.mode === "full" ? normalizePath(joinPath(dir, "includes.tsv")) : undefined,
@@ -253,6 +457,10 @@ export async function buildIndex(input: {
253
457
  * Re-scan only the files a caller names (an agent that just edited three files), merging rows into
254
458
  * the existing TSV. Kept as a whole-file rewrite: 69 MB rewritten in ~1 s is cheaper than a
255
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.
256
464
  */
257
465
  export async function patchIndex(index: IndexState, files: string[], resolveModule: (file: string) => { module: string; scope: IndexScope }): Promise<{ updated: number; added: number; removed: number }> {
258
466
  const rows = readDefRows(index);
@@ -298,10 +506,17 @@ function lineOffsets(path: string): { offsets: number[]; size: number } {
298
506
  if (cached && cached.size === size) return cached;
299
507
  const fd = openSync(path, "r");
300
508
  try {
301
- const buffer = Buffer.alloc(size);
302
- const read = readSync(fd, buffer, 0, size, 0);
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);
303
512
  const offsets: number[] = [0];
304
- for (let i = 0; i < read; i++) if (buffer[i] === 0x0a) offsets.push(i + 1);
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
+ }
305
520
  const entry = { offsets, size };
306
521
  lineIndexCache.set(path, entry);
307
522
  return entry;
@@ -348,6 +563,63 @@ function readDefRows(index: IndexState): DefRow[] {
348
563
  return readTsv(readFileSync(index.meta.defsPath, "utf8"), 3) as unknown as DefRow[];
349
564
  }
350
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
+
351
623
  function readFileRows(index: IndexState): { file: string; module: string; kind: string; symbols: number }[] {
352
624
  return readTsv(readFileSync(index.meta.filesPath, "utf8"), 3) as unknown as { file: string; module: string; kind: string; symbols: number }[];
353
625
  }
@@ -457,11 +729,11 @@ export function lookupSymbol(index: IndexState, query: string, options: { limit?
457
729
  // Pass 5: substring, opt-in. A linear pass over the TSV (~0.3 s for the 69 MB engine index) that
458
730
  // is only reached when every indexed pass missed, so the cost is paid by misses, not by hits.
459
731
  if (!out.length && options.substring) {
460
- for (const row of readDefRows(index)) {
461
- if (!probe(row.symbol).includes(needle)) continue;
732
+ forEachDefRow(index, (row) => {
733
+ if (!probe(row.symbol).includes(needle)) return;
462
734
  accept(row, 3);
463
- if (out.length >= limit) break;
464
- }
735
+ return out.length < limit;
736
+ });
465
737
  }
466
738
  return out.sort((a, b) => a.rank - b.rank || a.scope.localeCompare(b.scope) || a.symbol.localeCompare(b.symbol)).slice(0, limit);
467
739
  }
@@ -486,10 +758,10 @@ export function symbolsInFile(index: IndexState, file: string, limit: number): D
486
758
  const target = probe(file);
487
759
  const path = defPath(index);
488
760
  const out: DefRow[] = [];
489
- for (const row of readDefRows(index)) {
761
+ forEachDefRow(index, (row) => {
490
762
  if (probe(row.file) === target) out.push(row);
491
- if (out.length >= limit) break;
492
- }
763
+ return out.length < limit;
764
+ });
493
765
  return out;
494
766
  }
495
767
 
@@ -509,7 +781,9 @@ export interface ModuleInfo {
509
781
  export function listModules(index: IndexState, options: { limit?: number } = {}): ModuleInfo[] {
510
782
  const rows = readFileRows(index);
511
783
  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);
784
+ forEachDefRow(index, (row) => {
785
+ if (!scopes.has(row.module) && row.scope) scopes.set(row.module, row.scope);
786
+ });
513
787
  const map = new Map<string, ModuleInfo>();
514
788
  for (const row of rows) {
515
789
  const directory = row.file.split("/").slice(0, -1).join("/");
@@ -547,6 +821,51 @@ export function listFiles(index: IndexState, module: string, options: { kind?: s
547
821
  .slice(0, limit);
548
822
  }
549
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
+
550
869
  /* ──────────────────────────────────────────────────────────────────────── compile unit */
551
870
 
552
871
  export interface ContextUnit {
@@ -768,6 +1087,226 @@ export function pickIndex(filter: { key?: string; mode?: "definitions" | "full";
768
1087
  return { index: pool[0], candidates: pool, notes };
769
1088
  }
770
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
+
771
1310
  /* ──────────────────────────────────────────────────────────────────────── helpers */
772
1311
 
773
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");
@@ -804,12 +1343,27 @@ function writeJsonAtomic(path: string, value: unknown): void {
804
1343
  writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
805
1344
  }
806
1345
 
807
- /** Module attribution without Query data: the nearest `*.Build.cs` above the file. */
808
- export function moduleFromBuildCs(file: string): string {
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 {
809
1360
  const parts = normalizePath(file).split("/");
810
1361
  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;
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);
813
1367
  }
814
1368
  const marker = parts.indexOf("Source");
815
1369
  const candidate = marker >= 0 ? parts[marker + 1] : undefined;
@@ -817,6 +1371,30 @@ export function moduleFromBuildCs(file: string): string {
817
1371
  return parts[parts.length - 2] ?? "root";
818
1372
  }
819
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
+
820
1398
  /**
821
1399
  * Scan roots for an index build. A "root" is one module directory (the directory holding a
822
1400
  * `*.Build.cs`), because that is the unit UBT compiles and the unit a symbol's module belongs to.
@@ -831,8 +1409,16 @@ export interface RootOptions {
831
1409
  /** Plugin directories (absolute), e.g. from `PluginSummary.dir`. */
832
1410
  pluginDirs?: string[];
833
1411
  includeEngine?: boolean;
834
- /** Sub-directories of `Engine/Source` to scan. Default `DEFAULT_ENGINE_AREAS` (`["Runtime"]`). */
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
+ */
835
1419
  engineAreas?: string[];
1420
+ /** Declared module names per plugin root dir (from its `.uplugin`), used to name modules correctly. */
1421
+ pluginModuleNames?: Record<string, string[]>;
836
1422
  fs?: FsAdapter;
837
1423
  }
838
1424
 
@@ -848,11 +1434,12 @@ const SKIP_DIR_NAMES = new Set(["Intermediate", "Binaries", "Saved", ".git", ".v
848
1434
  * modules at `Runtime/Core`, `Runtime/Engine/Classes`... and areas are addressable as
849
1435
  * `engineAreas: ["Runtime/Core"]`, so the search must recurse (depth-capped, build-system dirs skipped).
850
1436
  */
851
- function moduleDirsUnder(dir: string, scope: ScanRoot["scope"], fs: FsAdapter, maxDepth = 3, depth = 0): ScanRoot[] {
1437
+ function moduleDirsUnder(dir: string, scope: ScanRoot["scope"], fs: FsAdapter, maxDepth = 3, depth = 0, moduleNames?: string[]): ScanRoot[] {
852
1438
  const roots: ScanRoot[] = [];
853
1439
  const here = normalizePath(dir);
854
- if (fs.listDir(here).some((entry) => entry.endsWith(".Build.cs"))) {
855
- roots.push({ dir: here, scope, module: baseName(here) });
1440
+ const names = buildCsNames(here);
1441
+ if (names.length) {
1442
+ roots.push({ dir: here, scope, module: pickModuleName(names, baseName(here), moduleNames) });
856
1443
  return roots; // a module's own tree never contains another module
857
1444
  }
858
1445
  if (depth >= maxDepth) return roots;
@@ -860,7 +1447,7 @@ function moduleDirsUnder(dir: string, scope: ScanRoot["scope"], fs: FsAdapter, m
860
1447
  const abs = joinPath(here, child);
861
1448
  if (!fs.isDirectory(abs)) continue;
862
1449
  if (SKIP_DIR_NAMES.has(child)) continue;
863
- roots.push(...moduleDirsUnder(abs, scope, fs, maxDepth, depth + 1));
1450
+ roots.push(...moduleDirsUnder(abs, scope, fs, maxDepth, depth + 1, moduleNames));
864
1451
  }
865
1452
  return roots;
866
1453
  }
@@ -879,7 +1466,8 @@ export function assembleRoots(options: RootOptions): { roots: ScanRoot[]; notes:
879
1466
 
880
1467
  for (const pluginDir of options.pluginDirs ?? []) {
881
1468
  const abs = normalizePath(pluginDir);
882
- const found = moduleDirsUnder(joinPath(abs, "Source"), "Plugin", fs);
1469
+ const moduleNames = options.pluginModuleNames?.[abs];
1470
+ const found = moduleDirsUnder(joinPath(abs, "Source"), "Plugin", fs, 3, 0, moduleNames);
883
1471
  roots.push(...found);
884
1472
  if (!found.length) notes.push(`plugin ${baseName(abs)}: no C++ module roots`);
885
1473
  }
@@ -888,7 +1476,9 @@ export function assembleRoots(options: RootOptions): { roots: ScanRoot[]; notes:
888
1476
  const engineRoot = normalizePath(options.engineRoot);
889
1477
  const areas = options.engineAreas?.length ? options.engineAreas : DEFAULT_ENGINE_AREAS;
890
1478
  for (const area of areas) {
891
- const found = moduleDirsUnder(joinPath(engineRoot, "Engine/Source", area), "Engine", fs, 4);
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);
892
1482
  roots.push(...found);
893
1483
  notes.push(`engine ${area}: ${found.length} module root(s)`);
894
1484
  }