homegraph 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,30 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
10
10
  ## [Unreleased]
11
11
 
12
12
 
13
+ ## [1.6.4] - 2026-11-05
14
+
15
+ ### Improvements
16
+
17
+ - Return and parameter types link to type nodes as `returns` / `type_of` edges (unique match only), so a type can be reverse-queried for methods that return or take it. Signature annotations are preferred; JSDoc `@returns` / `@param {Type}` fill gaps when the signature omits types. `nodes.return_type` is unchanged for chained-call inference (Spec 0066).
18
+
19
+ ### Fixes
20
+
21
+ - Fresh index store-worker: the main DB connection is closed before the worker opens (and reopened after it drains), so ArkTS Phase-A writes and the worker no longer share the file. Stops `SQLITE_BUSY` / 100% CPU hangs on MEMORY or non-WAL backends. Reclaim clears and re-ATTACHes `ohos_api` (stale in-memory path after reopen caused `no such table: ohos_api.nodes`). Store flush failures now abort the feed backpressure loop instead of spinning (Spec 0065).
22
+ - CLI `init` / `index` / `sync`: an explicit directory argument is used as-is. With no argument, the commands walk up from the cwd to the nearest `build-profile.json5` (Harmony project root), otherwise stay at cwd. They no longer adopt an ancestor `.homegraph` from a parent harness or monorepo, which could silently index the wrong tree (Spec 0061).
23
+ - ArkTS: after a modular batch, orphan `.ets` files (outside PROJECT modules; still tree-sitter TypeScript fallback per Spec 0036) no longer fail with `Failed to get parser for language: typescript` on ArkUI-only trees. Indexing preloads the TypeScript WASM whenever `arkts` is in the language set, including on the main thread when parse workers are enabled (Spec 0062).
24
+ - ArkUI `arkui-child` synthesis: when a PascalCase call name misses the name index (typical for framework `Column`/`Button`/`Text`), do not fall back to scanning every `struct`/`component`/`function`/`method` node — return no edge. Cuts large-repo resolveChild CPU (Spec 0063).
25
+ - ArkTS ViewTree lazy method nodes (including `%AM*` handlers): `file_path` and `contains` parent always follow `getDeclaringArkFile()` / declaring class, not the ViewTree importer that first walked the nested tree — so anonymous callbacks no longer drift onto `MainPageView`-style hosts (Spec 0064).
26
+
27
+ ## [1.6.3] - 2026-10-22
28
+
29
+ ### Improvements
30
+
31
+ - Release workflow jobs are split so a failed stage can be re-run alone: unit tests, CHANGELOG prepare, GitHub Release, npm publish, long registry verify, then OHOS API db packages.
32
+
33
+ ### Fixes
34
+
35
+ - ArkTS: cross-file `imports` and `extends`/`implements` edges are no longer dropped during per-file persist. They ride the same `crossFileEdges` path as calls. Built-in ArkUI decorators (`@State`, `@Builder`, …) are not fuzzy-matched onto unrelated same-named symbols (Spec 0059).
36
+
13
37
  ## [1.6.2] - 2026-10-19
14
38
 
15
39
  ### New Features
@@ -305,3 +329,5 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
305
329
  [1.6.0]: https://github.com/fujiaxin-coder/homegraph/releases/tag/v1.6.0
306
330
  [1.6.1]: https://github.com/fujiaxin-coder/homegraph/releases/tag/v1.6.1
307
331
  [1.6.2]: https://github.com/fujiaxin-coder/homegraph/releases/tag/v1.6.2
332
+ [1.6.3]: https://github.com/fujiaxin-coder/homegraph/releases/tag/v1.6.3
333
+ [1.6.4]: https://github.com/fujiaxin-coder/homegraph/releases/tag/v1.6.4
@@ -176,9 +176,12 @@ function main() {
176
176
  // Helper Functions
177
177
  // =============================================================================
178
178
  /**
179
- * Resolve project path from argument or current directory
180
- * Walks up parent directories to find nearest initialized HomeGraph project
181
- * (must have .homegraph/homegraph.db, not just .homegraph/lessons.db)
179
+ * Resolve project path for *read* CLI commands (status, query, …).
180
+ * Walks up to the nearest initialized HomeGraph project (has homegraph.db).
181
+ *
182
+ * Mutating commands (`init` / `index` / `sync`) must NOT use this — they use
183
+ * {@link resolveCliMutatingProjectRoot} (Spec 0061) so an ancestor harness
184
+ * `.homegraph` cannot hijack a nested Harmony project.
182
185
  */
183
186
  function resolveProjectPath(pathArg) {
184
187
  const absolutePath = path.resolve(pathArg || process.cwd());
@@ -445,12 +448,13 @@ function main() {
445
448
  */
446
449
  program
447
450
  .command('init [path]')
448
- .description('Initialize HomeGraph in a project directory and build the initial index')
451
+ .description('Initialize HomeGraph in a project directory and build the initial index (explicit path is literal; bare init uses nearest build-profile.json5)')
449
452
  .option('-i, --index', 'Deprecated: indexing now runs by default; flag accepted for backward compatibility')
450
453
  .option('-f, --force', 'Initialize even if the path looks like your home directory or a filesystem root')
451
454
  .option('-v, --verbose', 'Show detailed worker lifecycle and memory info')
452
455
  .action(async (pathArg, options) => {
453
- const projectPath = path.resolve(pathArg || process.cwd());
456
+ // Spec 0061: explicit path is literal; bare init walks up to build-profile.json5.
457
+ const projectPath = (0, directory_1.resolveCliMutatingProjectRoot)(pathArg);
454
458
  const clack = await importESM('@clack/prompts');
455
459
  clack.intro('Initializing HomeGraph');
456
460
  try {
@@ -569,13 +573,14 @@ function main() {
569
573
  */
570
574
  program
571
575
  .command('index [path]')
572
- .description('Rebuild the full index from scratch (same result as a fresh init)')
576
+ .description('Rebuild the full index from scratch (explicit path is literal; bare index uses nearest build-profile.json5)')
573
577
  .option('-f, --force', 'Index even if the path looks like your home directory or a filesystem root')
574
578
  .option('-q, --quiet', 'Suppress progress output')
575
579
  .option('-v, --verbose', 'Show detailed worker lifecycle and memory info')
576
580
  .option('--inplace', 'Index into the existing database without recreating it (MCP auto-init; DB may already be open by the parent)')
577
581
  .action(async (pathArg, options) => {
578
- const projectPath = resolveProjectPath(pathArg);
582
+ // Spec 0061: do not adopt an ancestor .homegraph (harness hijack).
583
+ const projectPath = (0, directory_1.resolveCliMutatingProjectRoot)(pathArg);
579
584
  try {
580
585
  // Don't (re)index your home directory / a filesystem root (#845). --force
581
586
  // doubles as the override.
@@ -647,10 +652,11 @@ function main() {
647
652
  */
648
653
  program
649
654
  .command('sync [path]')
650
- .description('Sync changes since last index')
655
+ .description('Sync changes since last index (explicit path is literal; bare sync uses nearest build-profile.json5)')
651
656
  .option('-q, --quiet', 'Suppress output (for git hooks)')
652
657
  .action(async (pathArg, options) => {
653
- const projectPath = resolveProjectPath(pathArg);
658
+ // Spec 0061: same root rules as init/index.
659
+ const projectPath = (0, directory_1.resolveCliMutatingProjectRoot)(pathArg);
654
660
  try {
655
661
  if (!(0, directory_1.isInitialized)(projectPath)) {
656
662
  if (!options.quiet) {
@@ -36,6 +36,11 @@ export declare class DatabaseConnection {
36
36
  * dead inode forever (#925). See `isReplacedOnDisk`.
37
37
  */
38
38
  private openedInode;
39
+ /**
40
+ * True after {@link closeForExclusiveWriter} until {@link reopenAfterExclusiveWriter}.
41
+ * The underlying handle is closed; callers must not use {@link getDb} in this window.
42
+ */
43
+ private releasedForExclusiveWriter;
39
44
  private constructor();
40
45
  /**
41
46
  * Initialize a new database at the given path
@@ -334,6 +339,22 @@ export declare class DatabaseConnection {
334
339
  * Check if the database connection is open
335
340
  */
336
341
  isOpen(): boolean;
342
+ /**
343
+ * Close this handle so another writer (fresh-index store-worker) can own the
344
+ * DB file exclusively. On-disk bulk-load state (dropped secondary indexes /
345
+ * missing FTS triggers) is preserved — do NOT use {@link open} to come back.
346
+ * Pair with {@link reopenAfterExclusiveWriter}. Spec 0065.
347
+ */
348
+ closeForExclusiveWriter(): void;
349
+ /**
350
+ * Re-open after {@link closeForExclusiveWriter}. Skips `open()` healers so a
351
+ * mid-`indexAll` bulk window stays open until `endBulk*`. When `fastInit` is
352
+ * set, restores MEMORY journal + OFF synchronous to match the pre-handoff
353
+ * fresh-index path. Spec 0065.
354
+ */
355
+ reopenAfterExclusiveWriter(opts: {
356
+ fastInit: boolean;
357
+ }): void;
337
358
  /**
338
359
  * True when the DB file at our path has been REPLACED on disk since we opened
339
360
  * it — a different inode now lives at the same path, so the fd we still hold
package/dist/db/index.js CHANGED
@@ -124,6 +124,11 @@ class DatabaseConnection {
124
124
  * dead inode forever (#925). See `isReplacedOnDisk`.
125
125
  */
126
126
  openedInode;
127
+ /**
128
+ * True after {@link closeForExclusiveWriter} until {@link reopenAfterExclusiveWriter}.
129
+ * The underlying handle is closed; callers must not use {@link getDb} in this window.
130
+ */
131
+ releasedForExclusiveWriter = false;
127
132
  constructor(db, dbPath, backend) {
128
133
  this.db = db;
129
134
  this.dbPath = dbPath;
@@ -753,14 +758,65 @@ class DatabaseConnection {
753
758
  * Close the database connection
754
759
  */
755
760
  close() {
761
+ this.releasedForExclusiveWriter = false;
756
762
  this.db.close();
757
763
  }
758
764
  /**
759
765
  * Check if the database connection is open
760
766
  */
761
767
  isOpen() {
768
+ if (this.releasedForExclusiveWriter)
769
+ return false;
762
770
  return this.db.open;
763
771
  }
772
+ /**
773
+ * Close this handle so another writer (fresh-index store-worker) can own the
774
+ * DB file exclusively. On-disk bulk-load state (dropped secondary indexes /
775
+ * missing FTS triggers) is preserved — do NOT use {@link open} to come back.
776
+ * Pair with {@link reopenAfterExclusiveWriter}. Spec 0065.
777
+ */
778
+ closeForExclusiveWriter() {
779
+ if (this.releasedForExclusiveWriter)
780
+ return;
781
+ this.db.close();
782
+ this.releasedForExclusiveWriter = true;
783
+ }
784
+ /**
785
+ * Re-open after {@link closeForExclusiveWriter}. Skips `open()` healers so a
786
+ * mid-`indexAll` bulk window stays open until `endBulk*`. When `fastInit` is
787
+ * set, restores MEMORY journal + OFF synchronous to match the pre-handoff
788
+ * fresh-index path. Spec 0065.
789
+ */
790
+ reopenAfterExclusiveWriter(opts) {
791
+ if (!this.releasedForExclusiveWriter) {
792
+ throw new Error('reopenAfterExclusiveWriter: connection was not released for exclusive writer');
793
+ }
794
+ const { db, backend } = (0, sqlite_adapter_1.createDatabase)(this.dbPath);
795
+ // Mirror configureConnection essentials without healBulkNodeLoad / migrations.
796
+ db.pragma('busy_timeout = 5000');
797
+ db.pragma('foreign_keys = ON');
798
+ if (opts.fastInit) {
799
+ try {
800
+ db.pragma('journal_mode = MEMORY');
801
+ db.pragma('synchronous = OFF');
802
+ }
803
+ catch {
804
+ /* keep whatever the backend allows */
805
+ }
806
+ }
807
+ else {
808
+ db.pragma('journal_mode = WAL');
809
+ db.pragma('synchronous = NORMAL');
810
+ }
811
+ db.pragma('cache_size = -16000');
812
+ db.pragma('temp_store = MEMORY');
813
+ db.pragma('mmap_size = 0');
814
+ db.pragma(`journal_size_limit = ${exports.WAL_HEAL_THRESHOLD_BYTES}`);
815
+ this.db = db;
816
+ this.backend = backend;
817
+ this.openedInode = statInode(this.dbPath);
818
+ this.releasedForExclusiveWriter = false;
819
+ }
764
820
  /**
765
821
  * True when the DB file at our path has been REPLACED on disk since we opened
766
822
  * it — a different inode now lives at the same path, so the fd we still hold
@@ -63,6 +63,23 @@ export declare function isInitialized(projectRoot: string): boolean;
63
63
  * The returned string is a human phrase that slots into "… looks like {reason}".
64
64
  */
65
65
  export declare function unsafeIndexRootReason(projectRoot: string): string | null;
66
+ /**
67
+ * Walk up from `startPath` looking for a HarmonyOS project root
68
+ * (`build-profile.json5`). Returns null if none is found before the
69
+ * filesystem root. Spec 0061.
70
+ */
71
+ export declare function findNearestHarmonyProjectRoot(startPath: string): string | null;
72
+ /**
73
+ * Resolve the project root for CLI mutating commands (`init` / `index` / `sync`).
74
+ *
75
+ * - Explicit `pathArg` → `path.resolve(cwd, pathArg)` literally (no walk-up).
76
+ * - Omitted / empty → nearest ancestor (or cwd) with `build-profile.json5`,
77
+ * else `cwd`. Never adopts an ancestor `.homegraph` that is not that root
78
+ * (Spec 0061 — fixes parent-harness index hijack).
79
+ *
80
+ * `cwd` is injectable for tests; production callers omit it.
81
+ */
82
+ export declare function resolveCliMutatingProjectRoot(pathArg?: string | null, cwd?: string): string;
66
83
  export declare function findNearestHomeGraphRoot(startPath: string, floor?: string): string | null;
67
84
  /**
68
85
  * Indexed sub-project roots beneath `root` (bounded breadth-first scan). For
package/dist/directory.js CHANGED
@@ -44,6 +44,8 @@ exports.isHomeGraphDataDir = isHomeGraphDataDir;
44
44
  exports.getHomeGraphDir = getHomeGraphDir;
45
45
  exports.isInitialized = isInitialized;
46
46
  exports.unsafeIndexRootReason = unsafeIndexRootReason;
47
+ exports.findNearestHarmonyProjectRoot = findNearestHarmonyProjectRoot;
48
+ exports.resolveCliMutatingProjectRoot = resolveCliMutatingProjectRoot;
47
49
  exports.findNearestHomeGraphRoot = findNearestHomeGraphRoot;
48
50
  exports.findIndexedSubprojectRoots = findIndexedSubprojectRoots;
49
51
  exports.hasStructuralKeyword = hasStructuralKeyword;
@@ -183,6 +185,44 @@ function unsafeIndexRootReason(projectRoot) {
183
185
  }
184
186
  return null;
185
187
  }
188
+ /**
189
+ * Walk up from `startPath` looking for a HarmonyOS project root
190
+ * (`build-profile.json5`). Returns null if none is found before the
191
+ * filesystem root. Spec 0061.
192
+ */
193
+ function findNearestHarmonyProjectRoot(startPath) {
194
+ let current = path.resolve(startPath);
195
+ const fsRoot = path.parse(current).root;
196
+ while (true) {
197
+ if (fs.existsSync(path.join(current, 'build-profile.json5'))) {
198
+ return current;
199
+ }
200
+ if (current === fsRoot)
201
+ break;
202
+ const parent = path.dirname(current);
203
+ if (parent === current)
204
+ break;
205
+ current = parent;
206
+ }
207
+ return null;
208
+ }
209
+ /**
210
+ * Resolve the project root for CLI mutating commands (`init` / `index` / `sync`).
211
+ *
212
+ * - Explicit `pathArg` → `path.resolve(cwd, pathArg)` literally (no walk-up).
213
+ * - Omitted / empty → nearest ancestor (or cwd) with `build-profile.json5`,
214
+ * else `cwd`. Never adopts an ancestor `.homegraph` that is not that root
215
+ * (Spec 0061 — fixes parent-harness index hijack).
216
+ *
217
+ * `cwd` is injectable for tests; production callers omit it.
218
+ */
219
+ function resolveCliMutatingProjectRoot(pathArg, cwd = process.cwd()) {
220
+ const base = path.resolve(cwd);
221
+ if (pathArg !== undefined && pathArg !== null && String(pathArg).trim() !== '') {
222
+ return path.resolve(base, String(pathArg).trim());
223
+ }
224
+ return findNearestHarmonyProjectRoot(base) ?? base;
225
+ }
186
226
  function findNearestHomeGraphRoot(startPath, floor) {
187
227
  // A floor (an MCP host's explicit --path / rootUri) bounds the walk: an
188
228
  // ancestor .homegraph above it must not be adopted — a stray ancestor index
@@ -386,6 +386,13 @@ function expandGrammarLanguages(languages) {
386
386
  if (languages.some((l) => l === 'cfml')) {
387
387
  languages = [...languages, 'cfscript', 'cfquery'];
388
388
  }
389
+ // ArkTS has no WASM of its own. After a modular AA batch, packageless /
390
+ // forgotten-HAR .ets that never entered a PROJECT module fall through to
391
+ // TreeSitterExtractor(..., 'typescript') as a shallow symbol fallback — so
392
+ // the TS grammar must be loaded even when the index set is pure .ets.
393
+ if (languages.some((l) => l === 'arkts')) {
394
+ languages = [...languages, 'typescript'];
395
+ }
389
396
  return languages;
390
397
  }
391
398
  /**
@@ -196,6 +196,11 @@ export declare class ExtractionOrchestrator {
196
196
  indexAll(onProgress?: (progress: IndexProgress) => void, signal?: AbortSignal, verbose?: boolean, walBackpressure?: () => Promise<void> | null, storeWriterOpts?: {
197
197
  dbPath: string;
198
198
  fastInit: boolean;
199
+ /** Spec 0065: close main DB before worker open; reclaim after worker close. */
200
+ exclusiveHandoff?: {
201
+ release: () => void;
202
+ reclaim: () => void;
203
+ };
199
204
  } | null): Promise<IndexResult>;
200
205
  /**
201
206
  * Index specific files
@@ -57,6 +57,7 @@ const child_process_1 = require("child_process");
57
57
  const tree_sitter_1 = require("./tree-sitter");
58
58
  const parse_pool_1 = require("./parse-pool");
59
59
  const store_writer_1 = require("./store-writer");
60
+ const store_flush_guard_1 = require("./store-flush-guard");
60
61
  const cooperative_yield_1 = require("../resolution/cooperative-yield");
61
62
  const grammars_1 = require("./grammars");
62
63
  const project_config_1 = require("../project-config");
@@ -1464,6 +1465,7 @@ class ExtractionOrchestrator {
1464
1465
  // advances (buffered items hold whole file contents, so this bound is
1465
1466
  // load-bearing for memory).
1466
1467
  while (nextSeq - nextToStore >= windowSize) {
1468
+ (0, store_flush_guard_1.throwIfStoreFlushFailed)(aborted, flushError);
1467
1469
  if (inFlight.size > 0)
1468
1470
  await Promise.race(inFlight);
1469
1471
  else
@@ -1505,16 +1507,43 @@ class ExtractionOrchestrator {
1505
1507
  });
1506
1508
  windowSize = Math.max(4, pool.size * 2);
1507
1509
  log(`Parse worker pool: ${poolSize} worker(s) (remaining files≈${remainingToParse})`);
1510
+ // ArkTS extraction is in-process (requiresInProcessExtraction). Orphan
1511
+ // .ets after the AA batch use TreeSitterExtractor('typescript') on this
1512
+ // thread — workers loading TS wasm does not help the main isolate.
1513
+ if (neededLanguages.includes('arkts')) {
1514
+ await (0, grammars_1.loadGrammarsForLanguages)(neededLanguages);
1515
+ }
1508
1516
  }
1509
1517
  else {
1510
1518
  await (0, grammars_1.loadGrammarsForLanguages)(neededLanguages);
1511
1519
  }
1512
1520
  const storeWorkerPath = path.join(__dirname, 'store-worker.js');
1521
+ /** Spec 0065: true after exclusiveHandoff.release until matching reclaim. */
1522
+ let storeHandoffReleased = false;
1523
+ const reclaimStoreHandoff = () => {
1524
+ if (!storeHandoffReleased)
1525
+ return;
1526
+ storeHandoffReleased = false;
1527
+ storeWriterOpts?.exclusiveHandoff?.reclaim();
1528
+ };
1513
1529
  if (storeWriterOpts &&
1514
1530
  process.env.HOMEGRAPH_NO_STORE_WORKER !== '1' &&
1515
1531
  fs.existsSync(storeWorkerPath)) {
1516
- storeWriter = new store_writer_1.StoreWriter(storeWorkerPath, storeWriterOpts.dbPath, storeWriterOpts.fastInit);
1517
- log('Store writer thread active');
1532
+ // Phase A (ArkTS / main-thread writes) is done; close the main connection
1533
+ // before the worker opens its own — dual MEMORY/DELETE connections are
1534
+ // what turn a transient SQLITE_BUSY into a hung index (Spec 0065).
1535
+ if (storeWriterOpts.exclusiveHandoff) {
1536
+ storeWriterOpts.exclusiveHandoff.release();
1537
+ storeHandoffReleased = true;
1538
+ }
1539
+ try {
1540
+ storeWriter = new store_writer_1.StoreWriter(storeWorkerPath, storeWriterOpts.dbPath, storeWriterOpts.fastInit);
1541
+ log('Store writer thread active');
1542
+ }
1543
+ catch (err) {
1544
+ reclaimStoreHandoff();
1545
+ throw err;
1546
+ }
1518
1547
  }
1519
1548
  // Parsing progress only after ArkTS batch (avoids false "Parsing - done").
1520
1549
  onProgress?.({
@@ -1523,135 +1552,152 @@ class ExtractionOrchestrator {
1523
1552
  total,
1524
1553
  });
1525
1554
  await new Promise((resolve) => setImmediate(resolve));
1526
- for (let i = 0; i < files.length; i += FILE_IO_BATCH_SIZE) {
1527
- if (signal?.aborted) {
1528
- aborted = true;
1529
- break;
1530
- }
1531
- const batch = files.slice(i, i + FILE_IO_BATCH_SIZE);
1532
- // Read files in parallel (with path validation before any I/O)
1533
- const fileContents = await Promise.all(batch.map(async (fp) => {
1534
- if ((0, arkts_1.isArkTSBatchPersisted)(fp)) {
1535
- return {
1536
- filePath: fp,
1537
- content: null,
1538
- stats: null,
1539
- error: null,
1540
- arktsBatchSkipped: true,
1541
- };
1542
- }
1543
- try {
1544
- // Indexing read: follow in-root symlinks the directory walk already
1545
- // descended into (the `../` guard still applies) so files reached
1546
- // via an in-root symlink-to-outside still index (#935).
1547
- const fullPath = (0, utils_1.validatePathWithinRoot)(this.rootDir, fp, { allowSymlinkEscape: true });
1548
- if (!fullPath) {
1549
- (0, errors_1.logWarn)('Path traversal blocked in batch reader', { filePath: fp });
1550
- return { filePath: fp, content: null, stats: null, error: new Error('Path traversal blocked') };
1551
- }
1552
- const content = await fsp.readFile(fullPath, 'utf-8');
1553
- const stats = await fsp.stat(fullPath);
1554
- return { filePath: fp, content, stats, error: null };
1555
- }
1556
- catch (err) {
1557
- return { filePath: fp, content: null, stats: null, error: err };
1558
- }
1559
- }));
1560
- // Dispatch each readable file into the bounded parse window; the window
1561
- // stores results on the main thread as they arrive.
1562
- for (const fileEntry of fileContents) {
1563
- const { filePath, content, stats, error } = fileEntry;
1564
- const arktsBatchSkipped = 'arktsBatchSkipped' in fileEntry && fileEntry.arktsBatchSkipped;
1555
+ try {
1556
+ for (let i = 0; i < files.length; i += FILE_IO_BATCH_SIZE) {
1565
1557
  if (signal?.aborted) {
1566
1558
  aborted = true;
1567
1559
  break;
1568
1560
  }
1569
- if ((0, arkts_1.isArkTSBatchPersisted)(filePath) || arktsBatchSkipped) {
1570
- processed++;
1571
- filesIndexed++;
1572
- onProgress?.({ phase: 'parsing', current: processed, total, currentFile: filePath });
1573
- continue;
1574
- }
1575
- if (!(0, context_1.isArktsBatchRunning)()) {
1576
- onProgress?.({
1577
- phase: 'parsing',
1578
- current: processed,
1579
- total,
1580
- currentFile: filePath,
1581
- });
1561
+ const batch = files.slice(i, i + FILE_IO_BATCH_SIZE);
1562
+ // Read files in parallel (with path validation before any I/O)
1563
+ const fileContents = await Promise.all(batch.map(async (fp) => {
1564
+ if ((0, arkts_1.isArkTSBatchPersisted)(fp)) {
1565
+ return {
1566
+ filePath: fp,
1567
+ content: null,
1568
+ stats: null,
1569
+ error: null,
1570
+ arktsBatchSkipped: true,
1571
+ };
1572
+ }
1573
+ try {
1574
+ // Indexing read: follow in-root symlinks the directory walk already
1575
+ // descended into (the `../` guard still applies) so files reached
1576
+ // via an in-root symlink-to-outside still index (#935).
1577
+ const fullPath = (0, utils_1.validatePathWithinRoot)(this.rootDir, fp, { allowSymlinkEscape: true });
1578
+ if (!fullPath) {
1579
+ (0, errors_1.logWarn)('Path traversal blocked in batch reader', { filePath: fp });
1580
+ return { filePath: fp, content: null, stats: null, error: new Error('Path traversal blocked') };
1581
+ }
1582
+ const content = await fsp.readFile(fullPath, 'utf-8');
1583
+ const stats = await fsp.stat(fullPath);
1584
+ return { filePath: fp, content, stats, error: null };
1585
+ }
1586
+ catch (err) {
1587
+ return { filePath: fp, content: null, stats: null, error: err };
1588
+ }
1589
+ }));
1590
+ // Dispatch each readable file into the bounded parse window; the window
1591
+ // stores results on the main thread as they arrive.
1592
+ for (const fileEntry of fileContents) {
1593
+ const { filePath, content, stats, error } = fileEntry;
1594
+ const arktsBatchSkipped = 'arktsBatchSkipped' in fileEntry && fileEntry.arktsBatchSkipped;
1595
+ if (signal?.aborted) {
1596
+ aborted = true;
1597
+ break;
1598
+ }
1599
+ if ((0, arkts_1.isArkTSBatchPersisted)(filePath) || arktsBatchSkipped) {
1600
+ processed++;
1601
+ filesIndexed++;
1602
+ onProgress?.({ phase: 'parsing', current: processed, total, currentFile: filePath });
1603
+ continue;
1604
+ }
1605
+ if (!(0, context_1.isArktsBatchRunning)()) {
1606
+ onProgress?.({
1607
+ phase: 'parsing',
1608
+ current: processed,
1609
+ total,
1610
+ currentFile: filePath,
1611
+ });
1612
+ }
1613
+ if (error || content === null || stats === null) {
1614
+ processed++;
1615
+ filesErrored++;
1616
+ errors.push({
1617
+ message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
1618
+ filePath,
1619
+ severity: 'error',
1620
+ code: 'read_error',
1621
+ });
1622
+ onProgress?.({ phase: 'parsing', current: processed, total });
1623
+ continue;
1624
+ }
1625
+ // Honour MAX_FILE_SIZE. Without this check, vendored generated
1626
+ // headers, minified bundles, and other multi-MB files get indexed,
1627
+ // wasting WASM heap and the worker recycle budget on inputs with no
1628
+ // useful symbols. The single-file extractFile path already enforces
1629
+ // this; the bulk path used to silently skip the check.
1630
+ if (stats.size > MAX_FILE_SIZE) {
1631
+ processed++;
1632
+ filesSkipped++;
1633
+ errors.push({
1634
+ message: `File exceeds max size (${stats.size} > ${MAX_FILE_SIZE})`,
1635
+ filePath,
1636
+ severity: 'warning',
1637
+ code: 'size_exceeded',
1638
+ });
1639
+ onProgress?.({ phase: 'parsing', current: processed, total });
1640
+ continue;
1641
+ }
1642
+ // Parse on the pool (main thread stays unblocked). Errors/timeouts are
1643
+ // handled inside feed() → recordParseFailure, feeding the retry pass.
1644
+ await feed(filePath, content, stats);
1582
1645
  }
1583
- if (error || content === null || stats === null) {
1584
- processed++;
1585
- filesErrored++;
1586
- errors.push({
1587
- message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
1588
- filePath,
1589
- severity: 'error',
1590
- code: 'read_error',
1591
- });
1592
- onProgress?.({ phase: 'parsing', current: processed, total });
1593
- continue;
1646
+ if (aborted)
1647
+ break;
1648
+ }
1649
+ // Drain parses still in flight (skip on abort — we tear down below instead),
1650
+ // then commit any results the cursor hasn't reached yet.
1651
+ if (!aborted) {
1652
+ await Promise.all(inFlight);
1653
+ await flushOrdered();
1654
+ if (flushError) {
1655
+ if (storeWriter) {
1656
+ await storeWriter.close();
1657
+ storeWriter = null;
1658
+ }
1659
+ throw flushError;
1594
1660
  }
1595
- // Honour MAX_FILE_SIZE. Without this check, vendored generated
1596
- // headers, minified bundles, and other multi-MB files get indexed,
1597
- // wasting WASM heap and the worker recycle budget on inputs with no
1598
- // useful symbols. The single-file extractFile path already enforces
1599
- // this; the bulk path used to silently skip the check.
1600
- if (stats.size > MAX_FILE_SIZE) {
1601
- processed++;
1602
- filesSkipped++;
1603
- errors.push({
1604
- message: `File exceeds max size (${stats.size} > ${MAX_FILE_SIZE})`,
1605
- filePath,
1606
- severity: 'warning',
1607
- code: 'size_exceeded',
1608
- });
1609
- onProgress?.({ phase: 'parsing', current: processed, total });
1610
- continue;
1661
+ if (storeWriter) {
1662
+ try {
1663
+ await storeWriter.drain();
1664
+ }
1665
+ finally {
1666
+ await storeWriter.close();
1667
+ storeWriter = null;
1668
+ }
1611
1669
  }
1612
- // Parse on the pool (main thread stays unblocked). Errors/timeouts are
1613
- // handled inside feed() → recordParseFailure, feeding the retry pass.
1614
- await feed(filePath, content, stats);
1615
1670
  }
1616
- if (aborted)
1617
- break;
1618
- }
1619
- // Drain parses still in flight (skip on abort — we tear down below instead),
1620
- // then commit any results the cursor hasn't reached yet.
1621
- if (!aborted) {
1622
- await Promise.all(inFlight);
1623
- await flushOrdered();
1624
- if (flushError) {
1625
- if (storeWriter)
1671
+ if (signal?.aborted || aborted) {
1672
+ if (storeWriter) {
1626
1673
  await storeWriter.close();
1627
- throw flushError;
1674
+ storeWriter = null;
1675
+ }
1676
+ if (pool)
1677
+ await pool.destroy();
1678
+ return {
1679
+ success: false,
1680
+ filesIndexed,
1681
+ filesSkipped,
1682
+ filesErrored,
1683
+ filesDiscovered: total,
1684
+ nodesCreated: totalNodes,
1685
+ edgesCreated: totalEdges,
1686
+ errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
1687
+ durationMs: Date.now() - startTime,
1688
+ };
1628
1689
  }
1690
+ }
1691
+ finally {
1692
+ // Reclaim before retry / endBulk* — those paths need the main connection.
1629
1693
  if (storeWriter) {
1630
1694
  try {
1631
- await storeWriter.drain();
1632
- }
1633
- finally {
1634
1695
  await storeWriter.close();
1635
- storeWriter = null;
1636
1696
  }
1697
+ catch { /* best-effort */ }
1698
+ storeWriter = null;
1637
1699
  }
1638
- }
1639
- if (signal?.aborted || aborted) {
1640
- if (storeWriter)
1641
- await storeWriter.close();
1642
- if (pool)
1643
- await pool.destroy();
1644
- return {
1645
- success: false,
1646
- filesIndexed,
1647
- filesSkipped,
1648
- filesErrored,
1649
- filesDiscovered: total,
1650
- nodesCreated: totalNodes,
1651
- edgesCreated: totalEdges,
1652
- errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
1653
- durationMs: Date.now() - startTime,
1654
- };
1700
+ reclaimStoreHandoff();
1655
1701
  }
1656
1702
  // Report 100% so the progress bar doesn't hang at 99%
1657
1703
  onProgress?.({