gitnexus 1.6.11-rc.2 → 1.6.11-rc.21

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.
Files changed (158) hide show
  1. package/README.md +43 -1
  2. package/dist/_shared/impact-risk.d.ts +37 -0
  3. package/dist/_shared/impact-risk.d.ts.map +1 -0
  4. package/dist/_shared/impact-risk.js +92 -0
  5. package/dist/_shared/impact-risk.js.map +1 -0
  6. package/dist/_shared/index.d.ts +2 -0
  7. package/dist/_shared/index.d.ts.map +1 -1
  8. package/dist/_shared/index.js +2 -0
  9. package/dist/_shared/index.js.map +1 -1
  10. package/dist/cli/ai-context.js +4 -4
  11. package/dist/cli/analyze-config.d.ts +2 -0
  12. package/dist/cli/analyze-config.js +16 -0
  13. package/dist/cli/analyze-options.d.ts +4 -0
  14. package/dist/cli/analyze.d.ts +17 -0
  15. package/dist/cli/analyze.js +43 -7
  16. package/dist/cli/eval-server.js +22 -5
  17. package/dist/cli/group.js +7 -1
  18. package/dist/cli/help-i18n.js +2 -0
  19. package/dist/cli/i18n/en.d.ts +12 -2
  20. package/dist/cli/i18n/en.js +12 -2
  21. package/dist/cli/i18n/resources.d.ts +22 -2
  22. package/dist/cli/i18n/zh-CN.d.ts +10 -0
  23. package/dist/cli/i18n/zh-CN.js +12 -2
  24. package/dist/cli/index.js +11 -4
  25. package/dist/cli/status.js +91 -6
  26. package/dist/cli/watch-queue.d.ts +41 -0
  27. package/dist/cli/watch-queue.js +184 -0
  28. package/dist/cli/watch.d.ts +20 -0
  29. package/dist/cli/watch.js +372 -0
  30. package/dist/cli/wiki.js +15 -2
  31. package/dist/config/ignore-service.d.ts +11 -0
  32. package/dist/config/ignore-service.js +41 -4
  33. package/dist/config/repo-control-file.d.ts +3 -0
  34. package/dist/config/repo-control-file.js +115 -0
  35. package/dist/core/group/config-parser.js +20 -2
  36. package/dist/core/group/cross-impact.d.ts +2 -1
  37. package/dist/core/group/cross-impact.js +33 -2
  38. package/dist/core/group/extractors/fs-utils.d.ts +2 -0
  39. package/dist/core/group/extractors/fs-utils.js +86 -0
  40. package/dist/core/group/extractors/graphql-extractor.d.ts +17 -0
  41. package/dist/core/group/extractors/graphql-extractor.js +652 -0
  42. package/dist/core/group/extractors/java-workspace-extractor.js +244 -31
  43. package/dist/core/group/extractors/manifest-extractor.d.ts +1 -1
  44. package/dist/core/group/extractors/manifest-extractor.js +1 -1
  45. package/dist/core/group/matching.js +8 -1
  46. package/dist/core/group/service.js +5 -1
  47. package/dist/core/group/storage.js +1 -0
  48. package/dist/core/group/sync.d.ts +3 -1
  49. package/dist/core/group/sync.js +43 -5
  50. package/dist/core/group/types.d.ts +19 -5
  51. package/dist/core/incremental/derived-writeback.d.ts +36 -0
  52. package/dist/core/incremental/derived-writeback.js +68 -0
  53. package/dist/core/incremental/subgraph-extract.d.ts +6 -4
  54. package/dist/core/incremental/subgraph-extract.js +7 -5
  55. package/dist/core/index-content-drift.d.ts +54 -0
  56. package/dist/core/index-content-drift.js +127 -0
  57. package/dist/core/ingestion/filesystem-walker.d.ts +15 -5
  58. package/dist/core/ingestion/filesystem-walker.js +20 -3
  59. package/dist/core/ingestion/frameworks/spring/dynamic-lookups.d.ts +22 -0
  60. package/dist/core/ingestion/frameworks/spring/dynamic-lookups.js +124 -0
  61. package/dist/core/ingestion/language-provider.d.ts +43 -0
  62. package/dist/core/ingestion/languages/csharp/razor-view-components.d.ts +62 -0
  63. package/dist/core/ingestion/languages/csharp/razor-view-components.js +954 -0
  64. package/dist/core/ingestion/languages/csharp/resolution-config.d.ts +3 -0
  65. package/dist/core/ingestion/languages/csharp/resolution-config.js +6 -1
  66. package/dist/core/ingestion/languages/csharp/scope-resolver.js +8 -0
  67. package/dist/core/ingestion/languages/java/capture-side-channel.d.ts +4 -0
  68. package/dist/core/ingestion/languages/java/capture-side-channel.js +16 -0
  69. package/dist/core/ingestion/languages/java/captures.js +14 -1
  70. package/dist/core/ingestion/languages/java/lombok-synthesizer.d.ts +42 -0
  71. package/dist/core/ingestion/languages/java/lombok-synthesizer.js +439 -0
  72. package/dist/core/ingestion/languages/java/scope-resolver.js +2 -0
  73. package/dist/core/ingestion/languages/java/spring-dynamic-lookup.d.ts +8 -0
  74. package/dist/core/ingestion/languages/java/spring-dynamic-lookup.js +62 -0
  75. package/dist/core/ingestion/languages/java.js +2 -0
  76. package/dist/core/ingestion/languages/jvm/accessor-synthesis.d.ts +99 -0
  77. package/dist/core/ingestion/languages/jvm/accessor-synthesis.js +173 -0
  78. package/dist/core/ingestion/languages/jvm/beanspec.d.ts +17 -0
  79. package/dist/core/ingestion/languages/jvm/beanspec.js +42 -0
  80. package/dist/core/ingestion/languages/kotlin/capture-side-channel.d.ts +5 -0
  81. package/dist/core/ingestion/languages/kotlin/capture-side-channel.js +16 -0
  82. package/dist/core/ingestion/languages/kotlin/captures.js +14 -1
  83. package/dist/core/ingestion/languages/kotlin/lombok-synthesizer.d.ts +26 -0
  84. package/dist/core/ingestion/languages/kotlin/lombok-synthesizer.js +417 -0
  85. package/dist/core/ingestion/languages/kotlin/scope-resolver.js +2 -0
  86. package/dist/core/ingestion/languages/kotlin/spring-dynamic-lookup.d.ts +8 -0
  87. package/dist/core/ingestion/languages/kotlin/spring-dynamic-lookup.js +77 -0
  88. package/dist/core/ingestion/languages/kotlin.js +2 -0
  89. package/dist/core/ingestion/pipeline-phases/di.js +47 -14
  90. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +3 -1
  91. package/dist/core/ingestion/pipeline-phases/parse-impl.js +57 -86
  92. package/dist/core/ingestion/pipeline-phases/parse.d.ts +2 -0
  93. package/dist/core/ingestion/pipeline-phases/runner.d.ts +4 -1
  94. package/dist/core/ingestion/pipeline-phases/runner.js +34 -14
  95. package/dist/core/ingestion/pipeline-phases/scan.js +25 -13
  96. package/dist/core/ingestion/pipeline.d.ts +6 -0
  97. package/dist/core/ingestion/pipeline.js +44 -12
  98. package/dist/core/ingestion/scope-extractor.js +1 -0
  99. package/dist/core/ingestion/utils/symbol-labels.d.ts +2 -2
  100. package/dist/core/ingestion/utils/symbol-labels.js +2 -2
  101. package/dist/core/ingestion/workers/parse-worker.js +28 -2
  102. package/dist/core/lbug/lbug-adapter.d.ts +59 -0
  103. package/dist/core/lbug/lbug-adapter.js +154 -1
  104. package/dist/core/run-analyze.d.ts +17 -0
  105. package/dist/core/run-analyze.js +231 -23
  106. package/dist/core/search/fts-indexes.d.ts +19 -1
  107. package/dist/core/search/fts-indexes.js +28 -1
  108. package/dist/core/wiki/generator.js +8 -0
  109. package/dist/core/wiki/grok-client.d.ts +21 -0
  110. package/dist/core/wiki/grok-client.js +287 -0
  111. package/dist/core/wiki/llm-client.d.ts +1 -1
  112. package/dist/core/wiki/llm-client.js +5 -2
  113. package/dist/core/wiki/local-cli-client.d.ts +11 -0
  114. package/dist/core/wiki/local-cli-client.js +22 -9
  115. package/dist/mcp/local/local-backend.d.ts +24 -7
  116. package/dist/mcp/local/local-backend.js +190 -81
  117. package/dist/mcp/local/pdg-impact.d.ts +8 -4
  118. package/dist/mcp/local/pdg-impact.js +7 -2
  119. package/dist/mcp/repository-policy.d.ts +5 -1
  120. package/dist/mcp/repository-policy.js +48 -4
  121. package/dist/mcp/resources.js +2 -1
  122. package/dist/mcp/server.js +6 -5
  123. package/dist/mcp/tools.js +31 -19
  124. package/dist/server/api.js +23 -64
  125. package/dist/server/grep-params.d.ts +18 -0
  126. package/dist/server/grep-params.js +83 -0
  127. package/dist/server/grep-scan.d.ts +23 -0
  128. package/dist/server/grep-scan.js +107 -0
  129. package/dist/server/grep-worker.d.ts +1 -0
  130. package/dist/server/grep-worker.js +12 -0
  131. package/dist/server/mcp-http.d.ts +8 -0
  132. package/dist/server/mcp-http.js +16 -1
  133. package/dist/storage/file-hash.d.ts +5 -0
  134. package/dist/storage/file-hash.js +16 -6
  135. package/dist/storage/fs-atomic.d.ts +24 -0
  136. package/dist/storage/fs-atomic.js +86 -2
  137. package/dist/storage/git.d.ts +19 -8
  138. package/dist/storage/git.js +83 -24
  139. package/dist/storage/gitnexus-managed-paths.d.ts +36 -0
  140. package/dist/storage/gitnexus-managed-paths.js +46 -0
  141. package/dist/storage/parse-cache.d.ts +22 -4
  142. package/dist/storage/parse-cache.js +106 -29
  143. package/dist/storage/parsedfile-store.d.ts +34 -60
  144. package/dist/storage/parsedfile-store.js +177 -171
  145. package/dist/storage/repo-manager.d.ts +11 -1
  146. package/dist/storage/repo-manager.js +23 -2
  147. package/dist/storage/repo-meta.d.ts +12 -0
  148. package/dist/storage/v8-sidecar.d.ts +48 -0
  149. package/dist/storage/v8-sidecar.js +347 -0
  150. package/dist/types/pipeline.d.ts +14 -0
  151. package/package.json +4 -1
  152. package/scripts/cross-platform-shard.ts +4 -2
  153. package/scripts/cross-platform-tests.ts +7 -1
  154. package/skills/gitnexus-cli.md +11 -3
  155. package/skills/gitnexus-impact-analysis.md +9 -0
  156. package/web/assets/{agent-Dr4l5EOp.js → agent-CFqT4hjR.js} +108 -104
  157. package/web/assets/{index-2zdvEdzg.js → index-BMIniRtX.js} +3 -3
  158. package/web/index.html +1 -1
@@ -12,6 +12,7 @@ import { detectGraphWriteCollapse } from './index-freshness.js';
12
12
  import { PDG_EDGE_TYPES } from './lbug/pdg-emit-sink.js';
13
13
  import path from 'path';
14
14
  import fs from 'fs/promises';
15
+ import { constants as fsConstants } from 'node:fs';
15
16
  import { randomUUID } from 'node:crypto';
16
17
  import { retryRename } from '../storage/fs-atomic.js';
17
18
  import { acquireIndexLock } from '../storage/index-lock.js';
@@ -20,11 +21,11 @@ import { logUnresolvedReceiverFiles, summarizeUnresolvedReceivers, } from './ing
20
21
  import { summarizeUndecidedSatisfaction } from './ingestion/scope-resolution/undecided-satisfaction.js';
21
22
  import { summarizeScopeExtractionFailures } from './ingestion/scope-resolution/scope-extraction-failures.js';
22
23
  import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
23
- import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, closeLbugBeforeExit, loadCachedEmbeddings, deleteNodesForFiles, ensureEmbeddingRowDmlSafe, ensureFtsRowDmlSafe, readIndexCatalogSnapshot, INDEX_CATALOG_UNREADABLE, deleteAllCommunitiesAndProcesses, deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, deleteAllAdvisedBy, deleteSpringAopEvidenceNodes, deleteSpringAutoConfigurationDeclarations, deleteSpringAutoConfigurationSyntheticClasses, queryImportersBatch, loadFTSExtension, wipeLbugDbFiles, LbugWipeError, DELETE_FILES_CHUNK_SIZE, } from './lbug/lbug-adapter.js';
24
+ import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, closeLbugBeforeExit, loadCachedEmbeddings, deleteNodesForFiles, nodeTablesWithRowsForFiles, snapshotDerivedRelsForFiles, restoreDerivedRels, ensureEmbeddingRowDmlSafe, ensureFtsRowDmlSafe, readIndexCatalogSnapshot, INDEX_CATALOG_UNREADABLE, deleteAllCommunitiesAndProcesses, deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, deleteAllAdvisedBy, deleteSpringAopEvidenceNodes, deleteSpringAutoConfigurationDeclarations, deleteSpringAutoConfigurationSyntheticClasses, queryImportersBatch, loadFTSExtension, wipeLbugDbFiles, LbugWipeError, DELETE_FILES_CHUNK_SIZE, } from './lbug/lbug-adapter.js';
24
25
  import { estimateBufferPool, setBufferPoolSizeHint, resolveNativeSafeStorageDir, } from './lbug/lbug-config.js';
25
26
  import { escapeCypherString } from './lbug/cypher-escape.js';
26
27
  import { chunk } from '../lib/utils.js';
27
- import { buildSearchIndexesOrDegrade, ftsFailureIsFatal, createSearchFTSIndexes, summarizeFtsIndexBuildFailures, dropSearchFTSIndexes, initialiseSearchFTSStemmer, verifySearchFTSIndexes, } from './search/fts-indexes.js';
28
+ import { buildSearchIndexesOrDegrade, ftsFailureIsFatal, createSearchFTSIndexes, summarizeFtsIndexBuildFailures, dropSearchFTSIndexes, missingSearchFTSIndexTables, initialiseSearchFTSStemmer, verifySearchFTSIndexes, } from './search/fts-indexes.js';
28
29
  import { cjkSegmentationModeMismatch, getSearchFTSCjkSegmentation, initialiseSearchFTSCjkSegmentation, } from './search/cjk-segmentation.js';
29
30
  import { getExtensionCapability, getExtensionCapabilities, getFtsCapability, resolveAnalyzeInstallPolicy, } from './lbug/extension-loader.js';
30
31
  import { diagnoseExtensionLoad } from './lbug/extension-load-error.js';
@@ -42,9 +43,13 @@ import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
42
43
  import { extractChangedSubgraph, computeEffectiveWriteSet, } from './incremental/subgraph-extract.js';
43
44
  import { shadowCandidatesFor } from './incremental/shadow-candidates.js';
44
45
  import { shouldEscalateIncrementalWrite } from './incremental/escalation-gate.js';
46
+ import { ftsTablesAmong, incrementalFtsTablesFromGraph, nodeTablesForIncrementalDelete, shouldPreservePersistedDerivedGraph, } from './incremental/derived-writeback.js';
47
+ import { NODE_TABLES } from './lbug/schema.js';
45
48
  import { loadParseCache, saveParseCache, pruneCache, PARSE_CACHE_VERSION, } from '../storage/parse-cache.js';
46
49
  import { getDurableParsedFileDir, pruneAndSaveDurableParsedFileStore, } from '../storage/parsedfile-store.js';
47
- import { getCurrentCommit, getCurrentBranch, getRemoteUrl, hasGitDir, getInferredRepoName, isWorkingTreeDirty, resolveRepoIdentityRoot, } from '../storage/git.js';
50
+ import { getCurrentCommit, getCurrentBranch, getRemoteUrl, hasGitDir, getInferredRepoName, isWorkingTreeDirty, listWorkingTreeDirtyPaths, resolveRepoIdentityRoot, } from '../storage/git.js';
51
+ import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js';
52
+ import { getMaxFileSizeBytes } from './ingestion/utils/max-file-size.js';
48
53
  import { generateAIContextFiles } from '../cli/ai-context.js';
49
54
  import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
50
55
  import { EMBEDDING_TABLE_NAME, EMBEDDING_DIMS, STALE_HASH_SENTINEL, SCHEMA_FINGERPRINT, schemaFingerprintMismatch, isSchemaFingerprintShaped, embeddingDimsMismatch, } from './lbug/schema.js';
@@ -149,6 +154,17 @@ function collectSpringBeanDeclarationDriftFiles(graph, persistedRows) {
149
154
  }
150
155
  return driftFiles;
151
156
  }
157
+ const liveIndexMutationRisks = new WeakSet();
158
+ function recordLiveIndexMutationRisk(error) {
159
+ if ((typeof error === 'object' && error !== null) || typeof error === 'function') {
160
+ liveIndexMutationRisks.add(error);
161
+ }
162
+ }
163
+ /** Whether a failed analyze may already have changed the live DB. */
164
+ export function analyzeFailureMayHaveMutatedLiveIndex(error) {
165
+ return (((typeof error === 'object' && error !== null) || typeof error === 'function') &&
166
+ liveIndexMutationRisks.has(error));
167
+ }
152
168
  /**
153
169
  * Logged when the optional FTS extension cannot be loaded or installed during
154
170
  * a full analyze. Kept as a named constant so the env-var/command guidance
@@ -784,6 +800,13 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
784
800
  }
785
801
  progress('fts', 90, 'Search indexes ready');
786
802
  progress('done', 100, 'Done');
803
+ if (options.registryName) {
804
+ await registerRepo(repoPath, existingMeta, {
805
+ name: options.registryName,
806
+ allowDuplicateName: options.allowDuplicateName,
807
+ branch: placement.branch,
808
+ });
809
+ }
787
810
  return {
788
811
  repoName: options.registryName ??
789
812
  getInferredRepoName(repoPath) ??
@@ -1118,6 +1141,29 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1118
1141
  // later read on a host where it loads — which is a legitimate, common
1119
1142
  // state, and the invariant `analyzer-identity-cli.test.ts` pins.
1120
1143
  if (!dirty && !healUnregistered) {
1144
+ if (options.registryName) {
1145
+ await registerRepo(repoPath, existingMeta, {
1146
+ name: options.registryName,
1147
+ allowDuplicateName: options.allowDuplicateName,
1148
+ branch: placement.branch,
1149
+ });
1150
+ if (!placement.branch) {
1151
+ try {
1152
+ await generateAIContextFiles(repoPath, storagePath, options.registryName, existingMeta.stats ?? {}, undefined, {
1153
+ skipAgentsMd: options.skipAgentsMd,
1154
+ skipSkills: options.skipSkills,
1155
+ noStats: options.noStats,
1156
+ defaultBranch: options.defaultBranch,
1157
+ // Fast path does not re-run PDG. Using `options.pdg` would
1158
+ // strip PDG bullets from AGENTS.md on a rename-only analyze.
1159
+ hasPdg: existingMeta.pdg != null,
1160
+ });
1161
+ }
1162
+ catch {
1163
+ /* best-effort — never fail the fast path over a context refresh */
1164
+ }
1165
+ }
1166
+ }
1121
1167
  // ── #2354: restamp the workspace label on a same-commit branch flip ──
1122
1168
  // The flat slot follows the checked-out working tree; a branch switch
1123
1169
  // at the SAME commit with a clean tree changes nothing the pipeline
@@ -1262,6 +1308,23 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1262
1308
  // pipeline has already run by then; that run emits non-streamed, precisely as
1263
1309
  // `resolveStreamPdgEmit` — read fresh at the same point — behaves.)
1264
1310
  const streamGraphEmitActive = resolveStreamGraphEmit(options);
1311
+ // #3016: hold back Leiden and flow extraction when the persisted metadata
1312
+ // says this run is a candidate for a surgical incremental write, whose
1313
+ // derived layer is reused rather than recomputed. Deliberately the same
1314
+ // conditions as the `isIncremental` decision below MINUS the two that only
1315
+ // the pipeline can answer (the analysis-feature re-check and a non-empty
1316
+ // file list), so this is a superset: every run that turns out incremental
1317
+ // had the phases skipped, and the runs that do not are caught by
1318
+ // `runDeferredDerivedPhases` once the write plan is known. Excluded on the
1319
+ // streaming path because that is a full rebuild by construction, and the
1320
+ // deferred phases must not write into a finalized emit sink.
1321
+ const skipDerivedGraphPhases = !streamGraphEmitActive &&
1322
+ !options.force &&
1323
+ !!existingMeta &&
1324
+ !!existingMeta.fileHashes &&
1325
+ Object.keys(existingMeta.fileHashes).length > 0 &&
1326
+ repoHasGit &&
1327
+ !schemaFingerprintMismatch(existingMeta.schemaFingerprint);
1265
1328
  // ── Phase 1: Full Pipeline (0–60%) ────────────────────────────────
1266
1329
  const pipelineResult = await runPipelineFromRepo(repoPath, (p) => {
1267
1330
  const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
@@ -1301,6 +1364,7 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1301
1364
  ? resolveNativeSafeStorageDir(storagePath, 'graph-csv')
1302
1365
  : undefined,
1303
1366
  fetchWrappers: options.fetchWrappers,
1367
+ skipDerivedGraphPhases,
1304
1368
  });
1305
1369
  // ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
1306
1370
  progress('lbug', 60, 'Loading into LadybugDB...');
@@ -1350,6 +1414,26 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1350
1414
  const hashDiff = isIncremental
1351
1415
  ? diffFileHashes(newFileHashes, existingMeta.fileHashes)
1352
1416
  : undefined;
1417
+ // #3016: `skipDerivedGraphPhases` was decided BEFORE the pipeline, from the
1418
+ // persisted metadata alone, so it can only ever be a bet that this run stays
1419
+ // surgical. Settle the bet here, where `isIncremental` and the deletion set
1420
+ // are both known, and pay it off by running the held-back phases whenever the
1421
+ // write plan needs a freshly derived layer:
1422
+ // - not incremental → full rebuild writes the whole graph, and a graph
1423
+ // with no Community/Process nodes would publish an
1424
+ // index with no communities and no flows;
1425
+ // - added/changed/deleted files → the persisted derived layer can miss new
1426
+ // symbols, keep stale memberships, or reference
1427
+ // removed ids. Only an empty file-hash diff is a
1428
+ // proof that Leiden/flows still match.
1429
+ const preserveDerivedLayer = skipDerivedGraphPhases &&
1430
+ isIncremental &&
1431
+ !!hashDiff &&
1432
+ shouldPreservePersistedDerivedGraph(hashDiff);
1433
+ if (skipDerivedGraphPhases && !preserveDerivedLayer) {
1434
+ progress('communities', 58, 'Detecting code communities and flows...');
1435
+ await pipelineResult.runDeferredDerivedPhases?.();
1436
+ }
1353
1437
  // #2 atomic index publish: on a full rebuild, build the fresh DB at a temp
1354
1438
  // path and swap it over the live index in one rename at the very end, so a
1355
1439
  // concurrent MCP reader opening mid-build only ever sees the previous
@@ -1378,11 +1462,14 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1378
1462
  const windowsSwapOk = process.platform === 'win32' &&
1379
1463
  options.pdg !== true &&
1380
1464
  process.env.GITNEXUS_ATOMIC_WINDOWS_SWAP === '1';
1381
- // Incremental atomicity copies the whole index into the temp before mutating
1382
- // it, which negates incremental's speed premise — so it is opt-in
1383
- // (GITNEXUS_ATOMIC_INCREMENTAL=1) pending a benchmark. Full rebuilds always
1384
- // swap where the platform allows.
1385
- const wantAtomicIncremental = isIncremental && !!hashDiff && process.env.GITNEXUS_ATOMIC_INCREMENTAL === '1';
1465
+ // Incremental atomicity stages the whole index before mutation. It remains
1466
+ // opt-in for ordinary analyze runs; watch mode requests it for failure
1467
+ // preservation. The copy requests a filesystem clone and records its actual
1468
+ // duration, while Node falls back to a normal copy where reflinks are absent.
1469
+ const wantAtomicIncremental = isIncremental &&
1470
+ !!hashDiff &&
1471
+ process.platform !== 'win32' &&
1472
+ (options.atomicIncremental === true || process.env.GITNEXUS_ATOMIC_INCREMENTAL === '1');
1386
1473
  // #2614 F3: the copy-then-swap stages ONLY the main lbug file, so a live index
1387
1474
  // carrying an orphan .wal/.shadow (a silently-failed prior checkpoint) would
1388
1475
  // be copied incompletely and lose that delta. Only take the atomic path when
@@ -1399,6 +1486,10 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1399
1486
  // valve. Nothing between here and there reads either binding except
1400
1487
  // `initLbug(buildPath)`, which the upgrade re-runs against the staging path.
1401
1488
  let useAtomicSwap = (isFullRebuild || atomicIncremental) && (posixSwap || windowsSwapOk);
1489
+ // Set only at the first operation that can mutate the live graph store.
1490
+ // Pre-write failures (config, lock, parsing, metadata, importer expansion)
1491
+ // remain retryable even when this platform cannot use an atomic swap.
1492
+ let liveIndexMutationStarted = false;
1402
1493
  // #2658: a per-run staging name (was the fixed `lbug.new`). Even under the
1403
1494
  // single-writer lock, a unique name means a crashed run's half-built staging
1404
1495
  // file can never be mistaken for — or clobber — a live run's; the lock's
@@ -1426,10 +1517,13 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1426
1517
  if (atomicIncremental) {
1427
1518
  // Stage the live index into the temp so the in-place delete/writeback
1428
1519
  // below mutates the COPY, and the end-of-run swap publishes it atomically.
1429
- // Clear any stale temp first (a crashed run), then copy the (consolidated,
1430
- // single-file) live index. Whole-file copy — hence opt-in.
1520
+ // Clear any stale temp first (a crashed run), then clone/copy the
1521
+ // consolidated single-file live index.
1431
1522
  await wipeLbugDbFiles(buildPath);
1432
- await fs.copyFile(lbugPath, buildPath);
1523
+ const copyStartedAt = Date.now();
1524
+ await fs.copyFile(lbugPath, buildPath, fsConstants.COPYFILE_FICLONE);
1525
+ log(`atomic-incremental: staged ${lbugPath} in ${Date.now() - copyStartedAt}ms ` +
1526
+ '(copy-on-write requested; filesystem fallback is allowed)');
1433
1527
  }
1434
1528
  }
1435
1529
  else {
@@ -1466,7 +1560,16 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1466
1560
  // (`buildPath` = `<lbugPath>.new`, clearing any stragglers from a crashed
1467
1561
  // run) and leaves the live index untouched until the end-of-run swap. On
1468
1562
  // Windows buildPath === lbugPath, so this is the original in-place wipe.
1469
- await wipeLbugDbFiles(buildPath);
1563
+ if (buildPath === lbugPath)
1564
+ liveIndexMutationStarted = true;
1565
+ try {
1566
+ await wipeLbugDbFiles(buildPath);
1567
+ }
1568
+ catch (error) {
1569
+ if (liveIndexMutationStarted)
1570
+ recordLiveIndexMutationRisk(error);
1571
+ throw error;
1572
+ }
1470
1573
  }
1471
1574
  // Size the buffer pool to the graph just built by the pipeline (a page cache
1472
1575
  // over the on-disk index, which scales with node/edge count) instead of the
@@ -1483,7 +1586,14 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1483
1586
  (pipelineResult.graphEmitManifest?.totalRows ?? 0)));
1484
1587
  // Full rebuild (POSIX) builds into the temp `buildPath`; incremental and
1485
1588
  // Windows use `buildPath === lbugPath` in place.
1486
- await initLbug(buildPath);
1589
+ try {
1590
+ await initLbug(buildPath);
1591
+ }
1592
+ catch (error) {
1593
+ if (liveIndexMutationStarted)
1594
+ recordLiveIndexMutationRisk(error);
1595
+ throw error;
1596
+ }
1487
1597
  // Manual WAL checkpoint driver (#1741): periodically drain the WAL
1488
1598
  // from JS so the un-retriable native auto-checkpoint almost never
1489
1599
  // has work left to do. Failures of the manual CHECKPOINT are absorbed
@@ -1506,6 +1616,7 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1506
1616
  // "escalated full write" (DB wiped, index destroyed) — tri-review
1507
1617
  // 4669518496 P1.
1508
1618
  let escalatedFullWrite = false;
1619
+ let incrementalStats;
1509
1620
  // Phase 3.5's restore scope (FIX 3 of this shipping review): on the
1510
1621
  // SURGICAL write plan this is the exact file set whose rows
1511
1622
  // deleteNodesForFiles just removed — only THOSE files' cached embedding
@@ -1521,6 +1632,7 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1521
1632
  // collapse check compares the whole in-memory graph against the whole DB,
1522
1633
  // which is only a like-for-like comparison on a full rebuild.
1523
1634
  let wroteChangedSubgraphOnly = false;
1635
+ let incrementalFtsRebuildTables;
1524
1636
  if (isIncremental && hashDiff) {
1525
1637
  // ── Incremental DB writeback ───────────────────────────────────
1526
1638
  // 0. Expand the writable set with transitive importers of
@@ -1624,6 +1736,13 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1624
1736
  }
1625
1737
  }
1626
1738
  const importerExpansion = writableFiles.size - directlyChangedCount;
1739
+ incrementalStats = {
1740
+ changedFiles: hashDiff.changed.length + hashDiff.added.length + hashDiff.deleted.length,
1741
+ reparsedFiles: pipelineResult.reparsedFileCount,
1742
+ affectedDependents: importerExpansion,
1743
+ deletedFiles: hashDiff.deleted.length,
1744
+ writeMode: 'incremental',
1745
+ };
1627
1746
  await saveIncrementalDirtyState('importer-bfs', {
1628
1747
  importerExpansion,
1629
1748
  shadowSeedCount: shadowSeed.length,
@@ -1776,6 +1895,14 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1776
1895
  const sizeForcedRebuild = shouldEscalateIncrementalWrite(filesToDelete.length, effectiveWriteSet.size, allFilePaths.length);
1777
1896
  if (extensionForcedRebuild || sizeForcedRebuild) {
1778
1897
  escalatedFullWrite = true;
1898
+ // #3016: escalation converts this run into a wipe + full bulk COPY of
1899
+ // the in-memory graph, so the derived layer the skip was betting on
1900
+ // preserving has to exist in that graph after all. Same reasoning as
1901
+ // the not-incremental branch above, just discovered later.
1902
+ if (preserveDerivedLayer) {
1903
+ progress('communities', 63, 'Detecting code communities and flows...');
1904
+ await pipelineResult.runDeferredDerivedPhases?.();
1905
+ }
1779
1906
  // Every live cause is named, not just the first: a DB can carry BOTH a
1780
1907
  // vector index and FTS indexes, and reporting one cause while the other
1781
1908
  // is equally fatal is how #2841 stayed mis-diagnosed for so long. §5.D:
@@ -1946,6 +2073,8 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1946
2073
  }
1947
2074
  await walCheckpointDriver.stop();
1948
2075
  await closeLbug();
2076
+ if (buildPath === lbugPath)
2077
+ liveIndexMutationStarted = true;
1949
2078
  await wipeLbugDbFiles(buildPath);
1950
2079
  await initLbug(buildPath);
1951
2080
  walCheckpointDriver = startWalCheckpointDriver();
@@ -1972,7 +2101,55 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1972
2101
  // same connection, and nothing on this branch creates or drops an index
1973
2102
  // in between — so re-reading would only weaken the one-read invariant
1974
2103
  // the snapshot type exists to enforce.
1975
- await dropSearchFTSIndexes(indexCatalogRows);
2104
+ if (buildPath === lbugPath)
2105
+ liveIndexMutationStarted = true;
2106
+ // FTS narrowing is independent of Leiden/flow reuse: even when this
2107
+ // run re-derives communities, Ladybug still cannot DML a live FTS
2108
+ // index (#2589), so only the tables this write set touches should
2109
+ // lose their index. The probe is a question about the DB rather than
2110
+ // the fresh graph — a symbol the edit DELETED is in no fresh graph
2111
+ // but is still a row that has to go.
2112
+ const tablesWithRows = await nodeTablesWithRowsForFiles(filesToDelete, NODE_TABLES);
2113
+ // Narrowing 1 — the FTS sweep, from "every configured index" to "the
2114
+ // indexes this run must touch". Three sources, and dropping any one of
2115
+ // them strands something:
2116
+ // - what the writeback DELETES (the probe above), because a symbol
2117
+ // the edit removed is in no fresh graph but is still a row;
2118
+ // - what it INSERTS (the fresh graph), because inserting under a live
2119
+ // FTS index is the same #2589 hazard as deleting under one;
2120
+ // - what is MISSING right now, because narrowing to the written
2121
+ // tables would otherwise leave keyword search degraded forever on
2122
+ // tables whose index a previous escalation dropped — the next full
2123
+ // rebuild would be the only thing that ever restored them.
2124
+ // An unreadable catalog proves nothing about that third set, so it
2125
+ // withdraws the narrowing entirely rather than guess.
2126
+ const missingFts = await missingSearchFTSIndexTables(indexCatalogRows);
2127
+ const touchedFts = missingFts
2128
+ ? new Set([
2129
+ ...ftsTablesAmong(tablesWithRows),
2130
+ ...incrementalFtsTablesFromGraph(pipelineResult.graph, new Set(filesToDelete)),
2131
+ ...missingFts,
2132
+ ])
2133
+ : undefined;
2134
+ // Graph-wide Spring synthetic Class nodes are DETACH DELETEd on this
2135
+ // branch even when Class is not in the write set
2136
+ // (`deleteSpringAutoConfigurationSyntheticClasses`). Always include
2137
+ // Class so class_fts is not live across that DML (#2589), including
2138
+ // when the fresh graph no longer materializes the synthetics but the
2139
+ // DB still holds them.
2140
+ if (touchedFts) {
2141
+ touchedFts.add('Class');
2142
+ }
2143
+ incrementalFtsRebuildTables = touchedFts;
2144
+ // MEMBER_OF / STEP_IN_PROCESS / ENTRY_POINT_OF edges hang off the nodes
2145
+ // the DETACH DELETE below removes, so preserving the Community/Process
2146
+ // nodes preserves only half the layer unless these are reattached after
2147
+ // the subgraph write puts the member nodes back. Only the probed tables
2148
+ // can own such an edge, so they are the only ones worth scanning.
2149
+ const derivedSnapshot = preserveDerivedLayer
2150
+ ? await snapshotDerivedRelsForFiles(filesToDelete, [...tablesWithRows])
2151
+ : [];
2152
+ await dropSearchFTSIndexes(indexCatalogRows, incrementalFtsRebuildTables);
1976
2153
  // 1b. Remove the write set's existing rows — batched (#2409): one
1977
2154
  // DETACH DELETE per table per 200-file chunk. The former per-file
1978
2155
  // loop issued a count + delete per table per FILE — ~13k
@@ -1986,6 +2163,9 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1986
2163
  progress('lbug', 62, `Removing rows for changed files (0/${filesToDelete.length})...`);
1987
2164
  await deleteNodesForFiles(filesToDelete, {
1988
2165
  onChunk: (done, total) => progress('lbug', 62, `Removing rows for changed files (${done}/${total})...`),
2166
+ nodeTables: incrementalFtsRebuildTables
2167
+ ? nodeTablesForIncrementalDelete(NODE_TABLES, incrementalFtsRebuildTables)
2168
+ : undefined,
1989
2169
  });
1990
2170
  // Surgical path: Phase 3.5 restores exactly these files' embedding
1991
2171
  // rows (FIX 3). Sound because deleteNodesForFiles propagates errors
@@ -1993,10 +2173,12 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
1993
2173
  // deterministically — and this process holds the exclusive DB lock,
1994
2174
  // so no concurrent writer can disturb the derivation.
1995
2175
  deletedFilePathsForRestore = new Set(filesToDelete);
1996
- // 2. Drop graph-wide nodes (Community, Process). They'll be re-inserted
1997
- // from the fresh pipeline output below. Required for the
1998
- // "Leiden runs on the FULL graph" correctness invariant.
1999
- await deleteAllCommunitiesAndProcesses();
2176
+ if (!preserveDerivedLayer) {
2177
+ // 2. Drop graph-wide nodes (Community, Process). They'll be re-inserted
2178
+ // from the fresh pipeline output below. Required for the
2179
+ // "Leiden runs on the FULL graph" correctness invariant.
2180
+ await deleteAllCommunitiesAndProcesses();
2181
+ }
2000
2182
  // 2a. Drop INJECTS edges (DI collection injection, #2200) — their
2001
2183
  // validity is a whole-program property (a third-file change to the
2002
2184
  // interface or an implementer creates/invalidates edges between two
@@ -2043,7 +2225,9 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
2043
2225
  // only that. Unchanged-file rows in the DB stay untouched. Pass
2044
2226
  // the SAME effectiveWriteSet so the subgraph and the deletes
2045
2227
  // cover identical files (asymmetry would silently corrupt).
2046
- const subgraph = extractChangedSubgraph(pipelineResult.graph, effectiveWriteSet);
2228
+ const subgraph = extractChangedSubgraph(pipelineResult.graph, effectiveWriteSet, {
2229
+ includeDerivedGraphWide: !preserveDerivedLayer,
2230
+ });
2047
2231
  wroteChangedSubgraphOnly = true;
2048
2232
  await saveIncrementalDirtyState('load-graph', {
2049
2233
  importerExpansion,
@@ -2056,6 +2240,9 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
2056
2240
  const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
2057
2241
  progress('lbug', pct, msg);
2058
2242
  });
2243
+ if (preserveDerivedLayer && derivedSnapshot.length > 0) {
2244
+ await restoreDerivedRels(derivedSnapshot);
2245
+ }
2059
2246
  }
2060
2247
  // Boundary drain (#2409): checkpoint at the end of the incremental
2061
2248
  // writeback so the WAL it accumulated never lingers into the FTS and
@@ -2107,6 +2294,7 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
2107
2294
  // pre-existing row (#2544/#2546) must not discard this run's otherwise-
2108
2295
  // successful graph/embeddings work — only keyword search degrades.
2109
2296
  const ftsResult = await buildSearchIndexesOrDegrade(executeQuery, {
2297
+ tables: incrementalFtsRebuildTables,
2110
2298
  onIndexStart: options.verbose
2111
2299
  ? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
2112
2300
  : undefined,
@@ -2751,8 +2939,9 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
2751
2939
  files: pipelineResult.totalFileCount,
2752
2940
  nodes: stats.nodes,
2753
2941
  edges: stats.edges,
2754
- communities: pipelineResult.communityResult?.stats.totalCommunities,
2755
- processes: pipelineResult.processResult?.stats.totalProcesses,
2942
+ communities: pipelineResult.communityResult?.stats.totalCommunities ??
2943
+ existingMeta?.stats?.communities,
2944
+ processes: pipelineResult.processResult?.stats.totalProcesses ?? existingMeta?.stats?.processes,
2756
2945
  embeddings: persistedEmbeddingCount,
2757
2946
  },
2758
2947
  capabilities: {
@@ -2828,6 +3017,12 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
2828
3017
  // absence has exactly one meaning — an index older than the field.
2829
3018
  embeddingDims: EMBEDDING_DIMS,
2830
3019
  fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined,
3020
+ indexCoverage: hasGitDir(repoPath)
3021
+ ? {
3022
+ maxFileSizeBytes: getMaxFileSizeBytes(),
3023
+ dirtyPaths: (listWorkingTreeDirtyPaths(repoPath) ?? Object.keys(newFileHashesRecord)).filter((rel) => newFileHashesRecord[rel] !== undefined && !isGitNexusManagedPath(rel)),
3024
+ }
3025
+ : undefined,
2831
3026
  // This branch's full live chunk-key set (#2106 R6). `usedKeys` is every
2832
3027
  // chunk hash touched in this scan — cache HITS included (see parse-impl
2833
3028
  // usedKeys.add) — so it's complete even on an incremental run. Persisted
@@ -2956,9 +3151,10 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
2956
3151
  files: pipelineResult.totalFileCount,
2957
3152
  nodes: stats.nodes,
2958
3153
  edges: stats.edges,
2959
- communities: pipelineResult.communityResult?.stats.totalCommunities,
3154
+ communities: pipelineResult.communityResult?.stats.totalCommunities ??
3155
+ existingMeta?.stats?.communities,
2960
3156
  clusters: aggregatedClusterCount,
2961
- processes: pipelineResult.processResult?.stats.totalProcesses,
3157
+ processes: pipelineResult.processResult?.stats.totalProcesses ?? existingMeta?.stats?.processes,
2962
3158
  }, undefined, {
2963
3159
  skipAgentsMd: options.skipAgentsMd,
2964
3160
  skipSkills: options.skipSkills,
@@ -3006,6 +3202,7 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
3006
3202
  : false;
3007
3203
  if (useAtomicSwap && builtDbExists) {
3008
3204
  await retryRename(buildPath, lbugPath);
3205
+ liveIndexMutationStarted = true;
3009
3206
  // Clear any sidecars orphaned beside the replaced file. A cleanly-closed
3010
3207
  // prior index has none; a crashed one could, and it would be replay
3011
3208
  // poison next to the freshly published index. Best-effort.
@@ -3037,6 +3234,12 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
3037
3234
  ftsSkipped: !ftsReady,
3038
3235
  ftsSkipReason: ftsReady ? undefined : ftsSkipReason,
3039
3236
  isPrimaryBranch: !placement.branch,
3237
+ incrementalStats: incrementalStats
3238
+ ? {
3239
+ ...incrementalStats,
3240
+ writeMode: escalatedFullWrite ? 'full' : 'incremental',
3241
+ }
3242
+ : undefined,
3040
3243
  };
3041
3244
  }
3042
3245
  catch (err) {
@@ -3079,6 +3282,11 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
3079
3282
  /* swallow — orphan reclamation must never mask the real failure */
3080
3283
  }
3081
3284
  }
3285
+ if (liveIndexMutationStarted) {
3286
+ // Preserve the original error identity/prototype: callers distinguish
3287
+ // IndexLockTimeoutError and other domain failures with `instanceof`.
3288
+ recordLiveIndexMutationRisk(err);
3289
+ }
3082
3290
  throw err;
3083
3291
  }
3084
3292
  }
@@ -59,6 +59,12 @@ export declare const SUPPORTED_FTS_STEMMERS: ReadonlySet<string>;
59
59
  export interface CreateSearchFTSIndexesOptions {
60
60
  onIndexStart?: (table: string, indexName: string) => void;
61
61
  onIndexReady?: (table: string, indexName: string) => void;
62
+ /**
63
+ * When set, only these node-table names are dropped/rebuilt (#3016).
64
+ * Omit to rebuild every configured FTS index (full analyze / deleted-file
65
+ * incremental / `--repair-fts`).
66
+ */
67
+ tables?: ReadonlySet<string>;
62
68
  }
63
69
  /**
64
70
  * Resolve + validate `GITNEXUS_FTS_STEMMER` once, up front at analyze startup,
@@ -98,7 +104,19 @@ export declare function getSearchFTSStemmer(): string;
98
104
  * contract, and the same one-shared-`SHOW_INDEXES`-read purpose, as the gates in
99
105
  * `lbug-adapter.ts`. Omit it to have the sweep read the catalog itself.
100
106
  */
101
- export declare function dropSearchFTSIndexes(indexRows?: IndexCatalogSnapshot): Promise<void>;
107
+ export declare function dropSearchFTSIndexes(indexRows?: IndexCatalogSnapshot, tables?: ReadonlySet<string>): Promise<void>;
108
+ /**
109
+ * The configured FTS tables whose index the catalog proves is ABSENT right now.
110
+ *
111
+ * `undefined` means the catalog could not be read, which proves nothing — the
112
+ * same fail-closed reading the sweep above applies. Callers narrowing a rebuild
113
+ * to a subset of tables (#3016) must union this in, or must not narrow at all
114
+ * when it is `undefined`: a run that rebuilds only the tables it wrote leaves
115
+ * keyword search permanently degraded on every table whose index went missing
116
+ * earlier (a prior escalation drops all of them, and only the next full rebuild
117
+ * would ever put them back).
118
+ */
119
+ export declare function missingSearchFTSIndexTables(indexRows?: IndexCatalogSnapshot): Promise<Set<string> | undefined>;
102
120
  /** One configured index that could not be (re)built, and why. */
103
121
  export interface FtsIndexBuildFailure {
104
122
  table: string;
@@ -172,7 +172,7 @@ export function getSearchFTSStemmer() {
172
172
  * contract, and the same one-shared-`SHOW_INDEXES`-read purpose, as the gates in
173
173
  * `lbug-adapter.ts`. Omit it to have the sweep read the catalog itself.
174
174
  */
175
- export async function dropSearchFTSIndexes(indexRows) {
175
+ export async function dropSearchFTSIndexes(indexRows, tables) {
176
176
  // One catalog read for the whole sweep, decided PER CONFIGURED INDEX on
177
177
  // IDENTITY (#2841 cleanup review). `undefined` = the catalog could not be
178
178
  // read, which proves nothing — attempt every drop rather than skip a real one,
@@ -193,6 +193,8 @@ export async function dropSearchFTSIndexes(indexRows) {
193
193
  // whether the sweep ran or not.
194
194
  const rows = await resolveGateRows(indexRows);
195
195
  for (const { table, indexName } of FTS_INDEXES) {
196
+ if (tables && !tables.has(table))
197
+ continue;
196
198
  // Skip only what the catalog POSITIVELY proves absent. Without this, a
197
199
  // machine whose FTS extension cannot load, analyzing a DB that never carried
198
200
  // an FTS index, pays one failed `CALL DROP_FTS_INDEX` per configured table on
@@ -209,6 +211,29 @@ export async function dropSearchFTSIndexes(indexRows) {
209
211
  await dropFTSIndex(table, indexName);
210
212
  }
211
213
  }
214
+ /**
215
+ * The configured FTS tables whose index the catalog proves is ABSENT right now.
216
+ *
217
+ * `undefined` means the catalog could not be read, which proves nothing — the
218
+ * same fail-closed reading the sweep above applies. Callers narrowing a rebuild
219
+ * to a subset of tables (#3016) must union this in, or must not narrow at all
220
+ * when it is `undefined`: a run that rebuilds only the tables it wrote leaves
221
+ * keyword search permanently degraded on every table whose index went missing
222
+ * earlier (a prior escalation drops all of them, and only the next full rebuild
223
+ * would ever put them back).
224
+ */
225
+ export async function missingSearchFTSIndexTables(indexRows) {
226
+ const rows = await resolveGateRows(indexRows);
227
+ if (rows === undefined)
228
+ return undefined;
229
+ const missing = new Set();
230
+ for (const { table, indexName } of FTS_INDEXES) {
231
+ const present = rows.some((row) => indexRowTable(row) === table && indexRowName(row) === indexName);
232
+ if (!present)
233
+ missing.add(table);
234
+ }
235
+ return missing;
236
+ }
212
237
  /**
213
238
  * Build every configured FTS index, and keep going when one of them fails
214
239
  * (#2889).
@@ -232,6 +257,8 @@ export async function createSearchFTSIndexes(options) {
232
257
  const stemmer = getSearchFTSStemmer();
233
258
  const failures = [];
234
259
  for (const { table, indexName, properties } of FTS_INDEXES) {
260
+ if (options?.tables && !options.tables.has(table))
261
+ continue;
235
262
  options?.onIndexStart?.(table, indexName);
236
263
  // Drop first so the live `properties` always win. `createFTSIndex` is
237
264
  // idempotent-by-name (skips when the index already exists), so without the
@@ -17,6 +17,7 @@ import { generateHTMLViewer } from './html-viewer.js';
17
17
  import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js';
18
18
  import { callLLM, estimateTokens, } from './llm-client.js';
19
19
  import { callCursorLLM, resolveCursorConfig } from './cursor-client.js';
20
+ import { callGrokLLM, resolveGrokConfig } from './grok-client.js';
20
21
  import { callClaudeLLM, callCodexLLM, callOpenCodeLLM, resolveLocalCLIConfig, } from './local-cli-client.js';
21
22
  import { GROUPING_SYSTEM_PROMPT, GROUPING_USER_PROMPT, MODULE_SYSTEM_PROMPT, MODULE_USER_PROMPT, PARENT_SYSTEM_PROMPT, PARENT_USER_PROMPT, OVERVIEW_SYSTEM_PROMPT, OVERVIEW_USER_PROMPT, fillTemplate, formatFileListForGrouping, formatDirectoryTree, formatCallEdges, formatProcesses, } from './prompts.js';
22
23
  import { shouldIgnorePath } from '../../config/ignore-service.js';
@@ -123,6 +124,13 @@ export class WikiGenerator {
123
124
  });
124
125
  return callCursorLLM(prompt, cursorConfig, systemPrompt, options);
125
126
  }
127
+ if (this.llmConfig.provider === 'grok') {
128
+ const grokConfig = resolveGrokConfig({
129
+ model: this.llmConfig.model,
130
+ requestTimeoutMs: this.llmConfig.requestTimeoutMs,
131
+ });
132
+ return callGrokLLM(prompt, grokConfig, systemPrompt, options);
133
+ }
126
134
  if (this.llmConfig.provider === 'claude' ||
127
135
  this.llmConfig.provider === 'codex' ||
128
136
  this.llmConfig.provider === 'opencode') {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Grok Build CLI client for wiki generation.
3
+ *
4
+ * Uses headless `grok --prompt-file` so large wiki prompts are not placed on
5
+ * argv or stdin (Grok does not read stdin as the prompt).
6
+ */
7
+ import type { CallLLMOptions, LLMResponse } from './llm-client.js';
8
+ export interface GrokConfig {
9
+ model?: string;
10
+ requestTimeoutMs?: number;
11
+ }
12
+ /** Returns `'grok'` when the CLI is on PATH, else null. Cached. */
13
+ export declare function detectGrokCLI(): string | null;
14
+ export declare function resolveGrokConfig(overrides?: Partial<GrokConfig>): GrokConfig;
15
+ /**
16
+ * Call Grok Build in headless mode and return the assistant text.
17
+ *
18
+ * `--cwd` is an empty temp directory (not the repo) so project AGENTS.md
19
+ * files are not injected into wiki generation.
20
+ */
21
+ export declare function callGrokLLM(prompt: string, config: GrokConfig, systemPrompt?: string, options?: CallLLMOptions): Promise<LLMResponse>;