homegraph 1.5.8 → 1.5.10

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.
@@ -67,8 +67,10 @@ exports.cleanArkTypeString = cleanArkTypeString;
67
67
  exports.normalizeArktsReturnType = normalizeArktsReturnType;
68
68
  exports.buildArkMethodSignatureFields = buildArkMethodSignatureFields;
69
69
  exports.shouldUseIsolatedArkTSBuild = shouldUseIsolatedArkTSBuild;
70
+ exports.preferHarmonySerialIndexing = preferHarmonySerialIndexing;
70
71
  exports.normalizeHarmonyModuleSrcPath = normalizeHarmonyModuleSrcPath;
71
72
  exports.listHarmonyProjectModules = listHarmonyProjectModules;
73
+ exports.syntheticRootConflictsWithHarmonyModules = syntheticRootConflictsWithHarmonyModules;
72
74
  exports.findSyntheticArkModuleRoot = findSyntheticArkModuleRoot;
73
75
  exports.listOrphanArkAnalyzerSources = listOrphanArkAnalyzerSources;
74
76
  exports.listSyntheticArkModuleRoots = listSyntheticArkModuleRoots;
@@ -319,6 +321,23 @@ function shouldUseModularArkTSBuild(rootDir) {
319
321
  return true;
320
322
  return fs.existsSync(path.join(rootDir, 'build-profile.json5'));
321
323
  }
324
+ /**
325
+ * Default to serial parse + no resolve worker pool on Harmony modular repos.
326
+ * analyseByModule dominates wall clock; worker pools mainly spike peak RSS after
327
+ * Scene release (Spec 0037). Explicit CODEGRAPH_PARSE_WORKERS still wins when set.
328
+ *
329
+ * - unset: true iff root `build-profile.json5` exists
330
+ * - `HOMEGRAPH_HARMONY_SERIAL=0`/`false`: never
331
+ * - `HOMEGRAPH_HARMONY_SERIAL=1`/`true`: always
332
+ */
333
+ function preferHarmonySerialIndexing(rootDir) {
334
+ const mode = process.env.HOMEGRAPH_HARMONY_SERIAL?.trim();
335
+ if (mode === '0' || mode === 'false')
336
+ return false;
337
+ if (mode === '1' || mode === 'true')
338
+ return true;
339
+ return fs.existsSync(path.join(rootDir, 'build-profile.json5'));
340
+ }
322
341
  /** Normalize build-profile `srcPath` / relative file paths for prefix matching. */
323
342
  function normalizeHarmonyModuleSrcPath(srcPath) {
324
343
  return srcPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
@@ -371,37 +390,72 @@ function resolveArkModuleAbsPath(rootDir, relSrcPath) {
371
390
  }
372
391
  }
373
392
  /**
374
- * Nearest package-like directory for an orphan Ark source, or the first path
375
- * segment. Never returns the project root (registering `.` would re-scan every
376
- * Harmony module). Lone files directly under the root return null.
393
+ * True when registering `srcPath` as a synthetic PROJECT would re-load real
394
+ * Harmony modules: exact path collision, or a parent of existing `srcPath`s
395
+ * (e.g. synthetic `feature/` swallowing `feature/appcenter`).
396
+ */
397
+ function syntheticRootConflictsWithHarmonyModules(srcPath, modules) {
398
+ const src = normalizeHarmonyModuleSrcPath(srcPath);
399
+ if (!src || src === '.' || src === '')
400
+ return true;
401
+ for (const m of modules) {
402
+ const mod = m.srcPath;
403
+ if (mod === src || mod.startsWith(`${src}/`))
404
+ return true;
405
+ }
406
+ return false;
407
+ }
408
+ /**
409
+ * Nearest **Node** `package.json` directory for an orphan Ark/TS source.
410
+ * Never returns the project root (registering `.` would re-scan every Harmony
411
+ * module). Lone files directly under the root return null.
412
+ *
413
+ * Only `package.json` counts — not `oh-package.json5`. Forgotten Harmony HARs
414
+ * left out of root `build-profile.json5` (scene_board `feature/visionglass`
415
+ * etc.) often declare heavy `file:`/`../` deps on modules already indexed as
416
+ * real PROJECT modules; registering them as synthetic PROJECT→BODIES reloads
417
+ * those deps into ModuleCache and spikes wall time + RSS for almost no unique
418
+ * coverage. Those orphans fall through to tree-sitter instead.
419
+ *
420
+ * When `harmonyModules` is provided, skips roots that would swallow those
421
+ * modules (e.g. a plugin under `feature/intelligent` must not become
422
+ * `synthetic:feature/` and re-BODIES the whole `feature/` tree).
377
423
  */
378
- function findSyntheticArkModuleRoot(rootDir, relFile) {
424
+ function findSyntheticArkModuleRoot(rootDir, relFile, harmonyModules = []) {
379
425
  const file = normIndexPath(relFile);
380
426
  if (!file || file.startsWith('@'))
381
427
  return null;
382
- let dir = path.posix.dirname(file);
428
+ const dir = path.posix.dirname(file);
383
429
  if (dir === '.' || dir === '')
384
430
  return null;
385
431
  const rootAbs = path.resolve(rootDir);
432
+ const candidates = [];
386
433
  let curAbs = path.resolve(rootDir, dir);
387
434
  for (;;) {
388
435
  const relFromRoot = path.relative(rootAbs, curAbs);
389
436
  if (!relFromRoot || relFromRoot.startsWith('..') || path.isAbsolute(relFromRoot))
390
437
  break;
391
- const rel = relFromRoot.replace(/\\/g, '/');
392
- if (fs.existsSync(path.join(curAbs, 'package.json')) ||
393
- fs.existsSync(path.join(curAbs, 'oh-package.json5'))) {
394
- return normalizeHarmonyModuleSrcPath(rel);
395
- }
438
+ const rel = normalizeHarmonyModuleSrcPath(relFromRoot.replace(/\\/g, '/'));
439
+ if (rel && rel !== '.')
440
+ candidates.push(rel);
396
441
  const parent = path.dirname(curAbs);
397
442
  if (parent === curAbs)
398
443
  break;
399
444
  curAbs = parent;
400
445
  }
401
- const first = file.split('/')[0];
402
- if (!first || first === file)
446
+ if (candidates.length === 0)
403
447
  return null;
404
- return normalizeHarmonyModuleSrcPath(first);
448
+ const safe = (rel) => !syntheticRootConflictsWithHarmonyModules(rel, harmonyModules);
449
+ // Nearest package.json that does not swallow Harmony modules (Node plugins).
450
+ for (const rel of candidates) {
451
+ if (!safe(rel))
452
+ continue;
453
+ const abs = path.resolve(rootDir, rel);
454
+ if (fs.existsSync(path.join(abs, 'package.json'))) {
455
+ return rel;
456
+ }
457
+ }
458
+ return null;
405
459
  }
406
460
  /**
407
461
  * ArkAnalyzer sources under `scannedFiles` that are not inside any
@@ -421,19 +475,21 @@ function listOrphanArkAnalyzerSources(scannedFiles, harmonyModules) {
421
475
  return orphans;
422
476
  }
423
477
  /**
424
- * Cluster orphan `.ets`/`.ts`/`.d.ts` into synthetic PROJECT module roots
425
- * (e.g. `HMRouterPlugin` for a Node hvigor plugin living beside HAP modules).
478
+ * Cluster orphan `.ets`/`.ts`/`.d.ts` into synthetic PROJECT module roots for
479
+ * Node-style packages only (`package.json`, e.g. HMRouterPlugin). Harmony
480
+ * `oh-package.json5` trees omitted from build-profile are not registered —
481
+ * analyseByModule would re-SIGNATURES their monorepo deps at high RSS cost.
426
482
  */
427
483
  function listSyntheticArkModuleRoots(rootDir, scannedFiles, harmonyModules) {
428
484
  const modules = harmonyModules ?? listHarmonyProjectModules(rootDir);
429
485
  const orphans = listOrphanArkAnalyzerSources(scannedFiles, modules);
430
486
  const bySrc = new Map();
431
487
  for (const f of orphans) {
432
- const srcPath = findSyntheticArkModuleRoot(rootDir, f);
488
+ const srcPath = findSyntheticArkModuleRoot(rootDir, f, modules);
433
489
  if (!srcPath)
434
490
  continue;
435
- // Never shadow a real Harmony module path.
436
- if (modules.some((m) => m.srcPath === srcPath))
491
+ // Belt-and-suspenders with findSyntheticArkModuleRoot's conflict check.
492
+ if (syntheticRootConflictsWithHarmonyModules(srcPath, modules))
437
493
  continue;
438
494
  if (bySrc.has(srcPath))
439
495
  continue;
@@ -914,14 +970,27 @@ function tryForceGc() {
914
970
  }
915
971
  }
916
972
  }
973
+ /** Compact stderr phase/heartbeat lines for ArkTS index stalls (scene_board-scale). */
974
+ function arktsPhaseLog(msg) {
975
+ const mu = process.memoryUsage();
976
+ const heap = (mu.heapUsed / 1024 / 1024).toFixed(0);
977
+ const rss = (mu.rss / 1024 / 1024).toFixed(0);
978
+ process.stderr.write(`\x1b[36m ArkTS[${new Date().toISOString()}] ${msg} (heap=${heap}MB rss=${rss}MB)\x1b[0m\n`);
979
+ }
917
980
  /**
918
981
  * Unload every module's IR then dispose Scene globals. Plain `scene.dispose()`
919
982
  * only clears SDK helpers / moduleCache — project ArkFiles stay reachable and
920
983
  * pin multi-GB RSS into Parsing/Resolving.
921
984
  */
922
985
  function releaseArkAnalyzerScene(scene) {
986
+ const t0 = Date.now();
987
+ let n = 0;
988
+ let total = -1;
923
989
  try {
924
- for (const mod of scene.getModules()) {
990
+ const mods = [...scene.getModules()];
991
+ total = mods.length;
992
+ arktsPhaseLog(`releaseScene: disposing ${total} module(s)…`);
993
+ for (const mod of mods) {
925
994
  try {
926
995
  scene.disposeModule(mod);
927
996
  mod.setFileDepGraph(undefined);
@@ -931,18 +1000,24 @@ function releaseArkAnalyzerScene(scene) {
931
1000
  catch {
932
1001
  // per-module best-effort
933
1002
  }
1003
+ n++;
1004
+ if (n === 1 || n === total || n % 25 === 0) {
1005
+ arktsPhaseLog(`releaseScene: disposed ${n}/${total}`);
1006
+ }
934
1007
  }
935
1008
  }
936
1009
  catch {
937
1010
  // ignore
938
1011
  }
939
1012
  try {
1013
+ arktsPhaseLog('releaseScene: scene.dispose()…');
940
1014
  scene.dispose();
941
1015
  }
942
1016
  catch {
943
1017
  // ignore
944
1018
  }
945
1019
  tryForceGc();
1020
+ arktsPhaseLog(`releaseScene: done in ${Date.now() - t0}ms (disposed ${n}/${total})`);
946
1021
  }
947
1022
  /**
948
1023
  * ArkAnalyzer 1.0.92 keeps INDEX hollow on {@link ModuleBuilder} as a private
@@ -1109,18 +1184,20 @@ function runArkTSBatch(rootDir, queries, triggerFile) {
1109
1184
  return cached;
1110
1185
  }
1111
1186
  // indexAll releases the in-memory payload after SQLite persist. A later
1112
- // ArkTSExtractor hit must NOT rebuild the entire Scene (looks like a restart).
1113
- if (batchBuildCommitted &&
1114
- persistedBatch &&
1115
- persistedBatch.rootDir === rootDir &&
1116
- batchPersistedPaths.has(normalizedTrigger)) {
1187
+ // ArkTSExtractor hit must NOT rebuild the entire Scene (looks like a restart
1188
+ // after "127/127 … releaseScene done"). That includes orphans that were never
1189
+ // in a PROJECT module — they are meant to fall through to tree-sitter, not
1190
+ // trigger a second analyseByModule without streamPersist.
1191
+ if (batchBuildCommitted && persistedBatch && persistedBatch.rootDir === rootDir) {
1117
1192
  return {
1118
1193
  fileResults: new Map(),
1119
1194
  crossFileEdges: [],
1120
1195
  nodeIds: new Set(),
1121
1196
  errors: [
1122
1197
  {
1123
- message: 'ArkTS batch already persisted this run; in-memory payload released',
1198
+ message: batchPersistedPaths.has(normalizedTrigger)
1199
+ ? 'ArkTS batch already persisted this run; in-memory payload released'
1200
+ : 'ArkTS batch already finished this run; file was outside PROJECT modules (tree-sitter fallback)',
1124
1201
  severity: 'warning',
1125
1202
  },
1126
1203
  ],
@@ -4035,7 +4112,7 @@ function buildArkTSIndexByModuleInner(rootDir, scannedFiles, options) {
4035
4112
  };
4036
4113
  try {
4037
4114
  scene.config(sceneConfig);
4038
- // Register build-profile modules + orphan trees (no oh-package) before the
4115
+ // Register build-profile modules + Node package.json orphan plugins before the
4039
4116
  // dep graph is frozen — otherwise analyseByModule never sees HMRouterPlugin-style .ts.
4040
4117
  const synthetics = prepareArkModulesWithSynthetics(scene, rootDir, scannedList);
4041
4118
  if (synthetics.length > 0) {
@@ -4079,67 +4156,115 @@ function buildArkTSIndexByModuleInner(rootDir, scannedFiles, options) {
4079
4156
  config.setIncludeType(arkanalyzer_1.ModuleType.PROJECT, true);
4080
4157
  process.stderr.write(`\n\x1b[33m ArkTS: analyseByModule (PROJECT→BODIES, deps→SIGNATURES, memoryLimitMB=${memoryLimitMB}, maxOldSpace=${(0, wasm_runtime_flags_1.resolveMaxOldSpaceSizeMb)()}${streamQueries ? ', streamPersist' : ''})...\x1b[0m\n`);
4081
4158
  }
4082
- scene.analyseByModule((module, scn) => {
4083
- const moduleFileRels = [...module.getFilesMap().values()]
4084
- .map((f) => adapter.normalizeRelPath(f.getFilePath()))
4085
- .filter((p) => p && isArkAnalyzerSourcePath(p))
4086
- .map(normIndexPath);
4087
- adapter.indexModule(module, scn);
4088
- // HomeGraph already captured this module's symbols/edges. Hollow every
4089
- // currently-loaded module still above INDEX (target + oh_modules /
4090
- // SIGNATURES deps left in ModuleCache). Topo order means PROJECT deps of
4091
- // the current target are often already INDEX from their own callback, so
4092
- // BFS-from-self alone left depHollowed=0; sweeping the cache catches the
4093
- // fat non-target SIGNATURES that memoryLimitMB rarely evicts under a large
4094
- // soft budget. INDEX keeps export-reachable shells; loadModule upgrades
4095
- // when a later target needs SIGNATURES/BODIES.
4096
- let depHollowed = 0;
4097
- try {
4098
- const builder = new arkanalyzer_1.ModuleBuilder(scn);
4099
- const selfId = scn.getModuleId(module);
4100
- hollowModuleToIndex(builder, selfId);
4101
- const cache = scn.getModuleCache();
4102
- if (cache) {
4103
- for (const modId of cache.getLoadedModules()) {
4104
- if (modId === selfId)
4105
- continue;
4106
- const depMod = builder.getModule(modId);
4107
- if (!depMod || depMod.getLoadState() <= arkanalyzer_1.ModuleLoadState.INDEX)
4108
- continue;
4109
- hollowModuleToIndex(builder, modId);
4110
- depHollowed++;
4111
- }
4112
- }
4113
- }
4114
- catch (e) {
4115
- const message = e instanceof Error ? e.message : String(e);
4116
- errors.push({
4117
- message: `ArkTS post-index INDEX hollow failed for ${module.getModuleName() || module.getModulePath()}: ${message}`,
4118
- severity: 'warning',
4119
- });
4159
+ let projectModuleCount = 0;
4160
+ try {
4161
+ for (const mod of scene.getModules()) {
4162
+ if (mod.getModuleType() === arkanalyzer_1.ModuleType.PROJECT)
4163
+ projectModuleCount++;
4120
4164
  }
4121
- if (streamQueries) {
4122
- // Persist this module's file bodies now; keep @dummyFile until finalize.
4123
- const slicePaths = moduleFileRels.filter((p) => p !== ARKANALYZER_DUMMY_FILE);
4124
- const slice = adapter.takeFileResults(slicePaths);
4165
+ }
4166
+ catch {
4167
+ projectModuleCount = -1;
4168
+ }
4169
+ // Heartbeat: analyseByModule is native between callbacks — without this the
4170
+ // CLI looks frozen after the last `indexed module` line (scene_board ~127).
4171
+ let lastCallbackAt = Date.now();
4172
+ let lastCallbackName = '(none)';
4173
+ let inCallback = false;
4174
+ const analyseStartedAt = Date.now();
4175
+ const heartbeat = setInterval(() => {
4176
+ const idleSec = ((Date.now() - lastCallbackAt) / 1000).toFixed(1);
4177
+ arktsPhaseLog(`heartbeat: analyseByModule still running — modulesDone=${modulesIndexed}/${projectModuleCount} ` +
4178
+ `last=${lastCallbackName} inCallback=${inCallback} idleSinceCallback=${idleSec}s ` +
4179
+ `elapsed=${((Date.now() - analyseStartedAt) / 1000).toFixed(0)}s`);
4180
+ }, 10_000);
4181
+ // Don't keep the process alive solely for the heartbeat if AA finishes.
4182
+ heartbeat.unref?.();
4183
+ arktsPhaseLog(`analyseByModule: ENTER expectedProjectModules=${projectModuleCount} scannedFiles=${scannedList.length}`);
4184
+ try {
4185
+ scene.analyseByModule((module, scn) => {
4186
+ inCallback = true;
4187
+ const callbackStarted = Date.now();
4188
+ const name = module.getModuleName() || path.basename(module.getModulePath());
4189
+ const gapSec = ((callbackStarted - lastCallbackAt) / 1000).toFixed(1);
4190
+ if (modulesIndexed > 0 && callbackStarted - lastCallbackAt >= 15_000) {
4191
+ arktsPhaseLog(`analyseByModule: gap before "${name}" — ${gapSec}s native since module ${modulesIndexed}`);
4192
+ }
4193
+ const moduleFileRels = [...module.getFilesMap().values()]
4194
+ .map((f) => adapter.normalizeRelPath(f.getFilePath()))
4195
+ .filter((p) => p && isArkAnalyzerSourcePath(p))
4196
+ .map(normIndexPath);
4197
+ const tIndex = Date.now();
4198
+ adapter.indexModule(module, scn);
4199
+ const indexMs = Date.now() - tIndex;
4200
+ // HomeGraph already captured this module's symbols/edges. Hollow every
4201
+ // currently-loaded module still above INDEX (target + oh_modules /
4202
+ // SIGNATURES deps left in ModuleCache). Topo order means PROJECT deps of
4203
+ // the current target are often already INDEX from their own callback, so
4204
+ // BFS-from-self alone left depHollowed=0; sweeping the cache catches the
4205
+ // fat non-target SIGNATURES that memoryLimitMB rarely evicts under a large
4206
+ // soft budget. INDEX keeps export-reachable shells; loadModule upgrades
4207
+ // when a later target needs SIGNATURES/BODIES.
4208
+ let depHollowed = 0;
4209
+ const tHollow = Date.now();
4125
4210
  try {
4126
- persistAndDropModuleSlice(rootDir, streamQueries, slice, streamedPersistedPaths, persistOrdinal);
4211
+ const builder = new arkanalyzer_1.ModuleBuilder(scn);
4212
+ const selfId = scn.getModuleId(module);
4213
+ hollowModuleToIndex(builder, selfId);
4214
+ const cache = scn.getModuleCache();
4215
+ if (cache) {
4216
+ for (const modId of cache.getLoadedModules()) {
4217
+ if (modId === selfId)
4218
+ continue;
4219
+ const depMod = builder.getModule(modId);
4220
+ if (!depMod || depMod.getLoadState() <= arkanalyzer_1.ModuleLoadState.INDEX)
4221
+ continue;
4222
+ hollowModuleToIndex(builder, modId);
4223
+ depHollowed++;
4224
+ }
4225
+ }
4127
4226
  }
4128
4227
  catch (e) {
4129
4228
  const message = e instanceof Error ? e.message : String(e);
4130
4229
  errors.push({
4131
- message: `ArkTS stream persist failed for ${module.getModuleName() || module.getModulePath()}: ${message}`,
4132
- severity: 'error',
4230
+ message: `ArkTS post-index INDEX hollow failed for ${module.getModuleName() || module.getModulePath()}: ${message}`,
4231
+ severity: 'warning',
4133
4232
  });
4134
- throw e;
4135
4233
  }
4136
- }
4137
- modulesIndexed++;
4138
- const name = module.getModuleName() || path.basename(module.getModulePath());
4139
- const st = arkanalyzer_1.ModuleLoadState[module.getLoadState()] ?? String(module.getLoadState());
4140
- const depNote = depHollowed > 0 ? `, hollowed ${depHollowed} deps` : '';
4141
- process.stderr.write(`\x1b[33m ArkTS: indexed module ${modulesIndexed}: ${name} → ${st}${depNote}${streamQueries ? ` (streamed ${moduleFileRels.length} files)` : ''}\x1b[0m\n`);
4142
- }, config);
4234
+ const hollowMs = Date.now() - tHollow;
4235
+ let persistMs = 0;
4236
+ if (streamQueries) {
4237
+ // Persist this module's file bodies now; keep @dummyFile until finalize.
4238
+ const slicePaths = moduleFileRels.filter((p) => p !== ARKANALYZER_DUMMY_FILE);
4239
+ const slice = adapter.takeFileResults(slicePaths);
4240
+ const tPersist = Date.now();
4241
+ try {
4242
+ persistAndDropModuleSlice(rootDir, streamQueries, slice, streamedPersistedPaths, persistOrdinal);
4243
+ }
4244
+ catch (e) {
4245
+ const message = e instanceof Error ? e.message : String(e);
4246
+ errors.push({
4247
+ message: `ArkTS stream persist failed for ${module.getModuleName() || module.getModulePath()}: ${message}`,
4248
+ severity: 'error',
4249
+ });
4250
+ throw e;
4251
+ }
4252
+ persistMs = Date.now() - tPersist;
4253
+ }
4254
+ modulesIndexed++;
4255
+ lastCallbackAt = Date.now();
4256
+ lastCallbackName = name;
4257
+ inCallback = false;
4258
+ const st = arkanalyzer_1.ModuleLoadState[module.getLoadState()] ?? String(module.getLoadState());
4259
+ const depNote = depHollowed > 0 ? `, hollowed ${depHollowed} deps` : '';
4260
+ const timingNote = ` [gap=${gapSec}s idx=${indexMs}ms hollow=${hollowMs}ms persist=${persistMs}ms total=${Date.now() - callbackStarted}ms]`;
4261
+ process.stderr.write(`\x1b[33m ArkTS: indexed module ${modulesIndexed}/${projectModuleCount}: ${name} → ${st}${depNote}${streamQueries ? ` (streamed ${moduleFileRels.length} files)` : ''}${timingNote}\x1b[0m\n`);
4262
+ }, config);
4263
+ }
4264
+ finally {
4265
+ clearInterval(heartbeat);
4266
+ }
4267
+ arktsPhaseLog(`analyseByModule: RETURNED modulesIndexed=${modulesIndexed}/${projectModuleCount} in ${Date.now() - analyseStartedAt}ms`);
4143
4268
  if (process.env.HOMEGRAPH_ARKTS_DIAG === '1') {
4144
4269
  const sceneDiag = scene;
4145
4270
  const ev = typeof sceneDiag.getModuleEvictionStats === 'function'
@@ -4158,7 +4283,10 @@ function buildArkTSIndexByModuleInner(rootDir, scannedFiles, options) {
4158
4283
  return emptyArkTSBatchIndex(errors);
4159
4284
  }
4160
4285
  try {
4286
+ const tFin = Date.now();
4287
+ arktsPhaseLog('finalizeCallGraph: ENTER');
4161
4288
  adapter.finalizeCallGraph(scene);
4289
+ arktsPhaseLog(`finalizeCallGraph: DONE in ${Date.now() - tFin}ms`);
4162
4290
  }
4163
4291
  catch (e) {
4164
4292
  const message = e instanceof Error ? e.message : String(e);
@@ -4166,10 +4294,14 @@ function buildArkTSIndexByModuleInner(rootDir, scannedFiles, options) {
4166
4294
  message: `ArkTS modular leftover RTA skipped: ${message}`,
4167
4295
  severity: 'warning',
4168
4296
  });
4297
+ arktsPhaseLog(`finalizeCallGraph: SKIPPED (${message})`);
4169
4298
  }
4170
4299
  if (streamQueries) {
4300
+ const tLeft = Date.now();
4301
+ arktsPhaseLog('leftoverPersist: ENTER');
4171
4302
  const leftover = adapter.takeFileResults(adapter.fileResultPaths());
4172
4303
  persistAndDropModuleSlice(rootDir, streamQueries, leftover, streamedPersistedPaths, persistOrdinal);
4304
+ arktsPhaseLog(`leftoverPersist: DONE in ${Date.now() - tLeft}ms leftoverFiles=${leftover.size} streamedTotal=${streamedPersistedPaths.size}`);
4173
4305
  }
4174
4306
  const index = adapter.toBatchIndex(errors);
4175
4307
  if (streamQueries) {
@@ -4182,6 +4314,7 @@ function buildArkTSIndexByModuleInner(rootDir, scannedFiles, options) {
4182
4314
  });
4183
4315
  return emptyArkTSBatchIndex(errors);
4184
4316
  }
4317
+ arktsPhaseLog('buildArkTSIndexByModuleInner: returning index (finally will releaseScene)');
4185
4318
  return index;
4186
4319
  }
4187
4320
  catch (e) {
@@ -4190,6 +4323,7 @@ function buildArkTSIndexByModuleInner(rootDir, scannedFiles, options) {
4190
4323
  message: `ArkTS analyseByModule failed: ${message}; falling back to full Scene build`,
4191
4324
  severity: 'warning',
4192
4325
  });
4326
+ arktsPhaseLog(`analyseByModule: FAILED — ${message}`);
4193
4327
  return emptyArkTSBatchIndex(errors);
4194
4328
  }
4195
4329
  finally {
@@ -4564,13 +4698,85 @@ function markOhosApiFilePath(relativePath) {
4564
4698
  function isOhosApiFilePath(filePath) {
4565
4699
  return filePath.startsWith(exports.OHOS_API_FILE_PREFIX);
4566
4700
  }
4567
- /** Strip json5 comments/trailing commas enough for compileSdkVersion extraction. */
4701
+ /**
4702
+ * DevEco / Harmony JSON5 subset → JSON.parse.
4703
+ * Supports: line/block comments, trailing commas, bare keys, single-quoted strings
4704
+ * (e.g. deviceTypes: ['phone', '2in1']). Not a full JSON5 implementation.
4705
+ */
4568
4706
  function parseJson5Minimal(text) {
4569
- const stripped = text
4707
+ let s = text
4570
4708
  .replace(/\/\/.*$/gm, '')
4571
- .replace(/\/\*[\s\S]*?\*\//g, '')
4572
- .replace(/,(\s*[}\]])/g, '$1');
4573
- return JSON.parse(stripped);
4709
+ .replace(/\/\*[\s\S]*?\*\//g, '');
4710
+ // Quote bare keys before touching string quotes.
4711
+ s = s.replace(/([{,]\s*)([A-Za-z_][\w]*)\s*:/g, '$1"$2":');
4712
+ s = convertJson5SingleQuotedStrings(s);
4713
+ s = s.replace(/,(\s*[}\]])/g, '$1');
4714
+ return JSON.parse(s);
4715
+ }
4716
+ /** Convert `'…'` string literals to JSON `"…"` outside existing double-quoted strings. */
4717
+ function convertJson5SingleQuotedStrings(input) {
4718
+ let out = '';
4719
+ let i = 0;
4720
+ while (i < input.length) {
4721
+ const c = input[i];
4722
+ if (c === '"') {
4723
+ out += c;
4724
+ i++;
4725
+ while (i < input.length) {
4726
+ const ch = input[i];
4727
+ out += ch;
4728
+ i++;
4729
+ if (ch === '\\' && i < input.length) {
4730
+ out += input[i];
4731
+ i++;
4732
+ continue;
4733
+ }
4734
+ if (ch === '"')
4735
+ break;
4736
+ }
4737
+ continue;
4738
+ }
4739
+ if (c === "'") {
4740
+ out += '"';
4741
+ i++;
4742
+ while (i < input.length) {
4743
+ const ch = input[i];
4744
+ if (ch === '\\' && i + 1 < input.length) {
4745
+ const next = input[i + 1];
4746
+ i += 2;
4747
+ if (next === "'") {
4748
+ out += "'";
4749
+ }
4750
+ else if (next === '"') {
4751
+ out += '\\"';
4752
+ }
4753
+ else if (next === '\\') {
4754
+ out += '\\\\';
4755
+ }
4756
+ else {
4757
+ out += '\\' + next;
4758
+ }
4759
+ continue;
4760
+ }
4761
+ if (ch === "'") {
4762
+ out += '"';
4763
+ i++;
4764
+ break;
4765
+ }
4766
+ if (ch === '"') {
4767
+ out += '\\"';
4768
+ i++;
4769
+ continue;
4770
+ }
4771
+ out += ch;
4772
+ i++;
4773
+ }
4774
+ continue;
4775
+ }
4776
+ out += c;
4777
+ i++;
4778
+ }
4779
+ return out;
4574
4780
  }
4575
4781
  /** Normalize compileSdkVersion values like "6.0.1(21)" → "6.0.1". */
4576
4782
  function normalizeOhosApiVersion(raw) {
@@ -6512,9 +6512,18 @@ function extractFromSource(filePath, source, language, frameworkNames) {
6512
6512
  result = extractor.extract();
6513
6513
  }
6514
6514
  else if (detectedLanguage === 'arkts') {
6515
- // HomeGraph uses the arkanalyzer-backed ArkTS extractor (not tree-sitter).
6516
- const extractor = new arkts_1.ArkTSExtractor(filePath, source);
6517
- result = extractor.extract();
6515
+ // Prefer ArkAnalyzer via ArkTSExtractor. After a committed modular/full batch,
6516
+ // files that never entered a PROJECT module must NOT call ArkTSExtractor —
6517
+ // that path would rebuild the entire Scene (second analyseByModule). Use the
6518
+ // TypeScript grammar as a shallow symbol fallback for those orphans.
6519
+ if ((0, arkts_1.isArkTSBatchCommitted)() && !(0, arkts_1.isArkTSBatchPersisted)(filePath)) {
6520
+ const extractor = new TreeSitterExtractor(filePath, source, 'typescript');
6521
+ result = extractor.extract();
6522
+ }
6523
+ else {
6524
+ const extractor = new arkts_1.ArkTSExtractor(filePath, source);
6525
+ result = extractor.extract();
6526
+ }
6518
6527
  }
6519
6528
  else if ((0, grammars_1.isFileLevelOnlyLanguage)(detectedLanguage)) {
6520
6529
  // No symbol extraction at this stage — files are tracked at the file-record
package/dist/index.js CHANGED
@@ -451,6 +451,11 @@ class HomeGraph {
451
451
  catch {
452
452
  return { success: false, filesIndexed: 0, filesSkipped: 0, filesErrored: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' }], durationMs: 0 };
453
453
  }
454
+ // Wall clock for the whole indexAll critical section (extract + FTS rebuild
455
+ // + resolve/link + maintenance + optional ohos API bind). Orchestrator
456
+ // durationMs only covers extract/store — printing that alone under-reports
457
+ // large Harmony repos where resolving dominates the remaining minutes.
458
+ const wallStartedAt = Date.now();
454
459
  const freshDb = this.queries.getNodeAndEdgeCount().nodes === 0;
455
460
  const fastInit = process.env.HOMEGRAPH_NO_FAST_INIT !== '1' && freshDb;
456
461
  if (fastInit) {
@@ -594,6 +599,7 @@ class HomeGraph {
594
599
  }
595
600
  }
596
601
  catch { /* metadata is advisory — never fail an index over it */ }
602
+ result.durationMs = Date.now() - wallStartedAt;
597
603
  return result;
598
604
  }
599
605
  finally {
@@ -1002,18 +1008,23 @@ class HomeGraph {
1002
1008
  * Processes chunks of unresolved refs, persisting results after each batch.
1003
1009
  */
1004
1010
  async resolveReferencesBatched(onProgress, onSynthesisProgress, backpressure) {
1005
- return this.resolver.resolveAndPersistBatched(onProgress, undefined, onSynthesisProgress, {
1006
- dbPath: this.db.getPath(),
1007
- bulkEdgeLoad: {
1008
- begin: () => this.db.beginBulkEdgeLoad(),
1009
- end: () => this.db.endBulkEdgeLoad(),
1010
- },
1011
- refIndexLoad: {
1012
- begin: () => this.db.beginBulkRefLoad(),
1013
- end: () => this.db.endBulkRefLoad(),
1014
- },
1015
- backpressure,
1016
- });
1011
+ // Harmony modular repos: skip ResolverPool (Spec 0037). Synthesis then runs
1012
+ // on the main thread with concurrency 1 — same peak profile as Plan D.
1013
+ const parallel = (0, arkts_1.preferHarmonySerialIndexing)(this.projectRoot)
1014
+ ? undefined
1015
+ : {
1016
+ dbPath: this.db.getPath(),
1017
+ bulkEdgeLoad: {
1018
+ begin: () => this.db.beginBulkEdgeLoad(),
1019
+ end: () => this.db.endBulkEdgeLoad(),
1020
+ },
1021
+ refIndexLoad: {
1022
+ begin: () => this.db.beginBulkRefLoad(),
1023
+ end: () => this.db.endBulkRefLoad(),
1024
+ },
1025
+ backpressure,
1026
+ };
1027
+ return this.resolver.resolveAndPersistBatched(onProgress, undefined, onSynthesisProgress, parallel);
1017
1028
  }
1018
1029
  /**
1019
1030
  * Get detected frameworks in the project
@@ -1,23 +1,31 @@
1
1
  /**
2
- * Product-facing index availability for MCP tool results (Spec 0032).
2
+ * Product-facing index availability for MCP tool results (Spec 0032 + 0035).
3
3
  *
4
- * Internal `build_phase` stays as-is; this layer maps it (+ write-lock / busy)
5
- * to the four states hosts and agents should see in tool text.
4
+ * Internal `build_phase` stays as-is; this layer maps it (+ write-lock / busy /
5
+ * pending dirty files) to the five states hosts and agents should see.
6
6
  */
7
7
  import type HomeGraph from '../index';
8
8
  /** Agent-visible index readiness (tool return copy — not tools/list gating). */
9
- export type ProductIndexState = 'empty' | 'fast' | 'full' | 'syncing';
10
- export declare function productIndexGuidance(state: Exclude<ProductIndexState, 'full'>): string;
9
+ export type ProductIndexState = 'empty' | 'fast' | 'full' | 'dirty' | 'syncing';
10
+ /** One-line glossary for MCP initialize / tool surface (Spec 0035). */
11
+ export declare const PRODUCT_STATUS_GLOSSARY = "status: empty=not ready \u00B7 fast=map only (homegraph_project) \u00B7 full=fresh \u00B7 dirty=usable but listed paths outdated \u00B7 syncing=write lock, retry";
12
+ /** Whole-response guidance when the tool cannot usefully answer yet. */
13
+ export declare function productIndexGuidance(state: Extract<ProductIndexState, 'empty' | 'fast' | 'syncing'>): string;
14
+ /**
15
+ * Single status line for tool footers (and for guidance-only replies).
16
+ * `pendingPaths` only used when `state === 'dirty'`.
17
+ */
18
+ export declare function formatProductStatusLine(state: ProductIndexState, opts?: {
19
+ pendingPaths?: string[];
20
+ }): string;
11
21
  /**
12
22
  * Resolve the product state from a live HomeGraph handle.
13
23
  *
14
- * - empty: fast map not ready (`none` / `building_fast`)
15
- * - fast: map ready, full index not done (`fast` / `indexing`)
16
- * - full: symbol index ready
17
- * - syncing: full (or empty-while-contended-init) and a writer holds the lock /
18
- * this process is indexing — reads may fail or see a moving target
24
+ * Priority: syncing > empty > fast > dirty > full
19
25
  */
20
26
  export declare function resolveProductIndexState(cg: HomeGraph): ProductIndexState;
21
27
  /** True when an error message indicates SQLite writer contention. */
22
28
  export declare function isSqliteBusyMessage(message: string): boolean;
29
+ /** True when text is already a pure (or leading) HomeGraph status line. */
30
+ export declare function textAlreadyHasProductStatus(text: string): boolean;
23
31
  //# sourceMappingURL=index-availability.d.ts.map