@remnic/core 9.6.37 → 9.6.39

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 (272) hide show
  1. package/dist/access-admin-ops-surface.d.ts +6 -6
  2. package/dist/access-admin-ops-surface.js +8 -8
  3. package/dist/access-boundary.d.ts +6 -6
  4. package/dist/access-boundary.js +9 -9
  5. package/dist/access-cli.js +32 -32
  6. package/dist/access-http.d.ts +6 -6
  7. package/dist/access-http.js +13 -13
  8. package/dist/access-identity-continuity-surface.d.ts +5 -5
  9. package/dist/access-identity-continuity-surface.js +8 -8
  10. package/dist/access-lcm-surface.d.ts +6 -6
  11. package/dist/access-lcm-surface.js +8 -8
  12. package/dist/access-mcp.d.ts +6 -6
  13. package/dist/access-mcp.js +12 -12
  14. package/dist/access-observe-write-surface.d.ts +6 -6
  15. package/dist/access-observe-write-surface.js +8 -8
  16. package/dist/access-operations-batch.js +10 -10
  17. package/dist/access-operations.d.ts +7 -7
  18. package/dist/access-operations.js +11 -11
  19. package/dist/access-recall-surface.d.ts +6 -6
  20. package/dist/access-recall-surface.js +8 -8
  21. package/dist/{access-service-BofDzaPT.d.ts → access-service-Fw9AYEqG.d.ts} +4 -4
  22. package/dist/access-service.d.ts +6 -6
  23. package/dist/access-service.js +8 -8
  24. package/dist/access-surface-catalog.d.ts +6 -6
  25. package/dist/action-confidence.d.ts +1 -1
  26. package/dist/active-memory-bridge.d.ts +1 -1
  27. package/dist/active-recall.d.ts +1 -1
  28. package/dist/active-recall.js +2 -1
  29. package/dist/active-recall.js.map +1 -1
  30. package/dist/behavior-learner.d.ts +1 -1
  31. package/dist/behavior-signals.d.ts +1 -1
  32. package/dist/bootstrap.d.ts +5 -5
  33. package/dist/briefing.d.ts +2 -2
  34. package/dist/briefing.js +3 -3
  35. package/dist/buffer-surprise-report.d.ts +1 -1
  36. package/dist/buffer.d.ts +2 -2
  37. package/dist/bulk-import/index.d.ts +3 -3
  38. package/dist/calibration.d.ts +1 -1
  39. package/dist/capabilities.d.ts +1 -1
  40. package/dist/{catalog-9WanSGtt.d.ts → catalog-CnS_MP-8.d.ts} +1 -1
  41. package/dist/causal-behavior.d.ts +1 -1
  42. package/dist/causal-consolidation.d.ts +1 -1
  43. package/dist/causal-consolidation.js +4 -4
  44. package/dist/causal-trajectory-graph.d.ts +1 -1
  45. package/dist/{chunk-B4LPRAWL.js → chunk-2V6L3L56.js} +6 -6
  46. package/dist/{chunk-AXALUFAH.js → chunk-3BZDBG7U.js} +2 -2
  47. package/dist/{chunk-KL4UAXUY.js → chunk-3WULMX34.js} +73 -2
  48. package/dist/{chunk-KL4UAXUY.js.map → chunk-3WULMX34.js.map} +1 -1
  49. package/dist/{chunk-JODHPNLN.js → chunk-5ELELET5.js} +2 -2
  50. package/dist/{chunk-DQ42IQSK.js → chunk-5SORASJK.js} +2 -2
  51. package/dist/{chunk-DAAJUA6Q.js → chunk-6MWGGPJJ.js} +5 -5
  52. package/dist/{chunk-2K3OSLHE.js → chunk-7TEWOG3R.js} +2 -2
  53. package/dist/{chunk-23G3T2Q5.js → chunk-7XY6TJLM.js} +2 -2
  54. package/dist/{chunk-W2AHSCKY.js → chunk-AZK3NEHL.js} +2 -2
  55. package/dist/{chunk-7ZYBDQ7V.js → chunk-CVEEJKRD.js} +2 -2
  56. package/dist/{chunk-YQB32INI.js → chunk-EBVUFGJ4.js} +2 -2
  57. package/dist/{chunk-4Q2XWAHZ.js → chunk-EDD2G35Z.js} +2 -2
  58. package/dist/{chunk-CAZ3Z5M6.js → chunk-F7AE7L6G.js} +2 -2
  59. package/dist/{chunk-B5GBWBCZ.js → chunk-GCTJG5MP.js} +2 -2
  60. package/dist/{chunk-C6TKSI35.js → chunk-HVE3UVAB.js} +3 -3
  61. package/dist/{chunk-SDAXPXER.js → chunk-IGCQHJSQ.js} +2 -2
  62. package/dist/{chunk-3AC5UYKI.js → chunk-METJNEIB.js} +1 -1
  63. package/dist/chunk-METJNEIB.js.map +1 -0
  64. package/dist/{chunk-MXAGJHTA.js → chunk-NREKOGZ2.js} +3 -3
  65. package/dist/{chunk-H4FHR4UM.js → chunk-PQG2AOL4.js} +2 -2
  66. package/dist/{chunk-WSOEYOHN.js → chunk-PWSZNML3.js} +15 -15
  67. package/dist/{chunk-OXVKR5OU.js → chunk-RU73ZROD.js} +342 -16
  68. package/dist/chunk-RU73ZROD.js.map +1 -0
  69. package/dist/{chunk-VJRJTRYF.js → chunk-RVLE2SNP.js} +17 -17
  70. package/dist/{chunk-KVSVVKHA.js → chunk-U5JP7FCM.js} +2 -2
  71. package/dist/{chunk-GQURC4B7.js → chunk-VTRF5QBX.js} +2 -2
  72. package/dist/{chunk-FVTLSFWU.js → chunk-WHZ7MK72.js} +2 -2
  73. package/dist/{chunk-PUQZIS6A.js → chunk-WX4XTAOI.js} +4 -4
  74. package/dist/{chunk-VHJARF5R.js → chunk-XH2Y6UIW.js} +2 -2
  75. package/dist/{chunk-GQGDRLDM.js → chunk-YAN6NTJN.js} +3 -3
  76. package/dist/{chunk-DEKIXXIG.js → chunk-ZIKJD6NL.js} +2 -2
  77. package/dist/{chunk-KJIJNLXT.js → chunk-ZMXXHUEZ.js} +3 -3
  78. package/dist/{chunk-O6YI4CVI.js → chunk-ZSDRGPMV.js} +2 -2
  79. package/dist/{cli-3_x5sJTL.d.ts → cli-PGF41MN6.d.ts} +5 -5
  80. package/dist/cli.d.ts +8 -8
  81. package/dist/cli.js +26 -26
  82. package/dist/compounding/engine.d.ts +2 -2
  83. package/dist/compounding/engine.js +3 -3
  84. package/dist/compounding/preference-consolidator.d.ts +1 -1
  85. package/dist/compression-optimizer.d.ts +1 -1
  86. package/dist/config.d.ts +1 -1
  87. package/dist/config.js +2 -1
  88. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  89. package/dist/connectors/codex-materialize-runner.js +3 -3
  90. package/dist/connectors/codex-materialize.d.ts +1 -1
  91. package/dist/connectors/index.d.ts +1 -1
  92. package/dist/connectors/index.js +3 -3
  93. package/dist/consolidation-provenance-check.d.ts +2 -2
  94. package/dist/consolidation-undo.d.ts +2 -2
  95. package/dist/contradiction/index.d.ts +2 -2
  96. package/dist/conversation-index/backend.d.ts +1 -1
  97. package/dist/conversation-index/chunker.d.ts +1 -1
  98. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  99. package/dist/conversation-index/indexer.d.ts +1 -1
  100. package/dist/conversation-index/search.d.ts +1 -1
  101. package/dist/day-summary.d.ts +1 -1
  102. package/dist/delinearize.d.ts +1 -1
  103. package/dist/direct-answer-wiring.d.ts +1 -1
  104. package/dist/direct-answer.d.ts +1 -1
  105. package/dist/embedding-fallback.d.ts +1 -1
  106. package/dist/enrichment/index.d.ts +1 -1
  107. package/dist/entity-retrieval.d.ts +2 -2
  108. package/dist/entity-retrieval.js +3 -3
  109. package/dist/entity-schema.d.ts +1 -1
  110. package/dist/explicit-capture.d.ts +5 -5
  111. package/dist/extraction-faithfulness.d.ts +1 -1
  112. package/dist/extraction-judge-telemetry.d.ts +1 -1
  113. package/dist/extraction-judge-training.d.ts +1 -1
  114. package/dist/extraction-judge.d.ts +1 -1
  115. package/dist/extraction.d.ts +1 -1
  116. package/dist/fallback-llm.d.ts +1 -1
  117. package/dist/graph-dashboard-diff.d.ts +1 -1
  118. package/dist/graph-dashboard-key.d.ts +1 -1
  119. package/dist/graph-dashboard-parser.d.ts +1 -1
  120. package/dist/graph-edge-reinforcement.d.ts +1 -1
  121. package/dist/graph-snapshot.d.ts +1 -1
  122. package/dist/graph.d.ts +1 -1
  123. package/dist/identity-continuity.d.ts +1 -1
  124. package/dist/importance.d.ts +1 -1
  125. package/dist/importers/index.d.ts +1 -1
  126. package/dist/index.d.ts +13 -13
  127. package/dist/index.js +35 -35
  128. package/dist/intent.d.ts +1 -1
  129. package/dist/lcm/engine.d.ts +1 -1
  130. package/dist/lcm/index.d.ts +1 -1
  131. package/dist/lcm/tools.d.ts +1 -1
  132. package/dist/lifecycle.d.ts +1 -1
  133. package/dist/live-connectors-runner.d.ts +1 -1
  134. package/dist/local-llm.d.ts +1 -1
  135. package/dist/local-model-endpoint.d.ts +1 -1
  136. package/dist/maintenance/memory-governance.d.ts +1 -1
  137. package/dist/maintenance/memory-governance.js +3 -3
  138. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  139. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  140. package/dist/mcp-memory-inspector-app.d.ts +6 -6
  141. package/dist/memory-action-policy.d.ts +1 -1
  142. package/dist/memory-cache.d.ts +1 -1
  143. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  144. package/dist/memory-projection-store.d.ts +1 -1
  145. package/dist/memory-provenance.d.ts +1 -1
  146. package/dist/memory-worth-outcomes.d.ts +2 -2
  147. package/dist/models-json.d.ts +1 -1
  148. package/dist/namespaces/migrate.d.ts +3 -3
  149. package/dist/namespaces/migrate.js +4 -4
  150. package/dist/namespaces/principal.d.ts +1 -1
  151. package/dist/namespaces/search.d.ts +1 -1
  152. package/dist/namespaces/storage.d.ts +3 -3
  153. package/dist/namespaces/storage.js +3 -3
  154. package/dist/native-knowledge.d.ts +1 -1
  155. package/dist/operator-toolkit.d.ts +2 -2
  156. package/dist/operator-toolkit.js +9 -8
  157. package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
  158. package/dist/orchestration/maintenance.d.ts +2 -2
  159. package/dist/orchestration/maintenance.js +5 -5
  160. package/dist/{orchestrator-CAH5-PKN.d.ts → orchestrator-xePY-WPe.d.ts} +4 -4
  161. package/dist/orchestrator.d.ts +5 -5
  162. package/dist/orchestrator.js +32 -32
  163. package/dist/patterns-cli.d.ts +1 -1
  164. package/dist/{pipeline-CiD86zIk.d.ts → pipeline-Zgwz-Kne.d.ts} +1 -1
  165. package/dist/policy-runtime.d.ts +1 -1
  166. package/dist/provenance.d.ts +1 -1
  167. package/dist/{qmd-Bef7DDuQ.d.ts → qmd-B6Xwfn5_.d.ts} +1 -1
  168. package/dist/qmd-preflight.d.ts +2 -2
  169. package/dist/qmd-recall-cache.d.ts +1 -1
  170. package/dist/qmd.d.ts +2 -2
  171. package/dist/recall-disclosure-escalation.d.ts +1 -1
  172. package/dist/recall-explain-renderer.d.ts +1 -1
  173. package/dist/recall-explain-renderer.js +3 -3
  174. package/dist/recall-planner-llm.d.ts +1 -1
  175. package/dist/recall-state.d.ts +1 -1
  176. package/dist/recall-tag-filter.d.ts +1 -1
  177. package/dist/recall-timings.d.ts +1 -1
  178. package/dist/recall-xray-cli.d.ts +1 -1
  179. package/dist/recall-xray-cli.js +4 -4
  180. package/dist/recall-xray-renderer.d.ts +1 -1
  181. package/dist/recall-xray-renderer.js +3 -3
  182. package/dist/recall-xray.d.ts +1 -1
  183. package/dist/recall-xray.js +2 -2
  184. package/dist/replay/normalizers/chatgpt.d.ts +1 -1
  185. package/dist/replay/normalizers/claude.d.ts +1 -1
  186. package/dist/replay/normalizers/openclaw.d.ts +1 -1
  187. package/dist/replay/normalizers/shared.d.ts +1 -1
  188. package/dist/replay/runner.d.ts +1 -1
  189. package/dist/replay/types.d.ts +1 -1
  190. package/dist/resolve-auth-token.d.ts +1 -1
  191. package/dist/resume-bundles.js +3 -2
  192. package/dist/retrieval-agents.d.ts +2 -2
  193. package/dist/retrieval-tiers.d.ts +1 -1
  194. package/dist/routing/engine.d.ts +1 -1
  195. package/dist/routing/store.d.ts +1 -1
  196. package/dist/schemas.d.ts +2 -2
  197. package/dist/search/embed-helper.d.ts +1 -1
  198. package/dist/search/factory.d.ts +1 -1
  199. package/dist/search/index.d.ts +1 -1
  200. package/dist/search/lancedb-backend.d.ts +1 -1
  201. package/dist/search/meilisearch-backend.d.ts +1 -1
  202. package/dist/search/noop-backend.d.ts +1 -1
  203. package/dist/search/orama-backend.d.ts +1 -1
  204. package/dist/search/port.d.ts +1 -1
  205. package/dist/search/remote-backend.d.ts +1 -1
  206. package/dist/{semantic-consolidation-BTShF2Fo.d.ts → semantic-consolidation-ClfD_CWg.d.ts} +1 -1
  207. package/dist/semantic-consolidation.d.ts +2 -2
  208. package/dist/semantic-consolidation.js +4 -4
  209. package/dist/semantic-rule-promotion.js +3 -3
  210. package/dist/semantic-rule-verifier.d.ts +1 -1
  211. package/dist/semantic-rule-verifier.js +3 -3
  212. package/dist/session-observer-bands.d.ts +1 -1
  213. package/dist/session-observer-state.d.ts +1 -1
  214. package/dist/shared-context/manager.d.ts +3 -3
  215. package/dist/signal.d.ts +1 -1
  216. package/dist/{storage-DxPDAiU9.d.ts → storage-ZrHZ0dSM.d.ts} +1 -1
  217. package/dist/storage.d.ts +2 -2
  218. package/dist/storage.js +2 -2
  219. package/dist/summarizer.d.ts +1 -1
  220. package/dist/summary-snapshot.d.ts +1 -1
  221. package/dist/temporal-supersession.d.ts +2 -2
  222. package/dist/temporal-timeline-recall.d.ts +1 -1
  223. package/dist/temporal-validity.d.ts +1 -1
  224. package/dist/threading.d.ts +1 -1
  225. package/dist/tier-migration.d.ts +2 -2
  226. package/dist/tier-routing.d.ts +1 -1
  227. package/dist/topics.d.ts +1 -1
  228. package/dist/transcript.d.ts +1 -1
  229. package/dist/transfer/types.d.ts +10 -10
  230. package/dist/trust-score-stage.d.ts +1 -1
  231. package/dist/trust-score.d.ts +1 -1
  232. package/dist/{types-DDUDgd7T.d.ts → types-BbtQZBq9.d.ts} +33 -1
  233. package/dist/types.d.ts +1 -1
  234. package/dist/types.js +1 -1
  235. package/dist/utility-runtime.d.ts +1 -1
  236. package/dist/verified-recall.js +3 -3
  237. package/package.json +2 -2
  238. package/src/coding/codegraph-runtime.ts +539 -1
  239. package/src/coding/codegraph-surfaces.test.ts +75 -0
  240. package/src/coding/codegraph-surfaces.ts +34 -0
  241. package/src/coding/coding-knowledge-config.ts +100 -2
  242. package/src/types.ts +30 -0
  243. package/dist/chunk-3AC5UYKI.js.map +0 -1
  244. package/dist/chunk-OXVKR5OU.js.map +0 -1
  245. /package/dist/{chunk-B4LPRAWL.js.map → chunk-2V6L3L56.js.map} +0 -0
  246. /package/dist/{chunk-AXALUFAH.js.map → chunk-3BZDBG7U.js.map} +0 -0
  247. /package/dist/{chunk-JODHPNLN.js.map → chunk-5ELELET5.js.map} +0 -0
  248. /package/dist/{chunk-DQ42IQSK.js.map → chunk-5SORASJK.js.map} +0 -0
  249. /package/dist/{chunk-DAAJUA6Q.js.map → chunk-6MWGGPJJ.js.map} +0 -0
  250. /package/dist/{chunk-2K3OSLHE.js.map → chunk-7TEWOG3R.js.map} +0 -0
  251. /package/dist/{chunk-23G3T2Q5.js.map → chunk-7XY6TJLM.js.map} +0 -0
  252. /package/dist/{chunk-W2AHSCKY.js.map → chunk-AZK3NEHL.js.map} +0 -0
  253. /package/dist/{chunk-7ZYBDQ7V.js.map → chunk-CVEEJKRD.js.map} +0 -0
  254. /package/dist/{chunk-YQB32INI.js.map → chunk-EBVUFGJ4.js.map} +0 -0
  255. /package/dist/{chunk-4Q2XWAHZ.js.map → chunk-EDD2G35Z.js.map} +0 -0
  256. /package/dist/{chunk-CAZ3Z5M6.js.map → chunk-F7AE7L6G.js.map} +0 -0
  257. /package/dist/{chunk-B5GBWBCZ.js.map → chunk-GCTJG5MP.js.map} +0 -0
  258. /package/dist/{chunk-C6TKSI35.js.map → chunk-HVE3UVAB.js.map} +0 -0
  259. /package/dist/{chunk-SDAXPXER.js.map → chunk-IGCQHJSQ.js.map} +0 -0
  260. /package/dist/{chunk-MXAGJHTA.js.map → chunk-NREKOGZ2.js.map} +0 -0
  261. /package/dist/{chunk-H4FHR4UM.js.map → chunk-PQG2AOL4.js.map} +0 -0
  262. /package/dist/{chunk-WSOEYOHN.js.map → chunk-PWSZNML3.js.map} +0 -0
  263. /package/dist/{chunk-VJRJTRYF.js.map → chunk-RVLE2SNP.js.map} +0 -0
  264. /package/dist/{chunk-KVSVVKHA.js.map → chunk-U5JP7FCM.js.map} +0 -0
  265. /package/dist/{chunk-GQURC4B7.js.map → chunk-VTRF5QBX.js.map} +0 -0
  266. /package/dist/{chunk-FVTLSFWU.js.map → chunk-WHZ7MK72.js.map} +0 -0
  267. /package/dist/{chunk-PUQZIS6A.js.map → chunk-WX4XTAOI.js.map} +0 -0
  268. /package/dist/{chunk-VHJARF5R.js.map → chunk-XH2Y6UIW.js.map} +0 -0
  269. /package/dist/{chunk-GQGDRLDM.js.map → chunk-YAN6NTJN.js.map} +0 -0
  270. /package/dist/{chunk-DEKIXXIG.js.map → chunk-ZIKJD6NL.js.map} +0 -0
  271. /package/dist/{chunk-KJIJNLXT.js.map → chunk-ZMXXHUEZ.js.map} +0 -0
  272. /package/dist/{chunk-O6YI4CVI.js.map → chunk-ZSDRGPMV.js.map} +0 -0
@@ -23,11 +23,15 @@
23
23
  * by the surface handler via a context seam.
24
24
  */
25
25
  import path from "node:path";
26
+ import { readFile, realpath } from "node:fs/promises";
27
+ import { readFileSync, realpathSync } from "node:fs";
28
+ import { pathToFileURL } from "node:url";
26
29
  import { createHash } from "node:crypto";
27
30
  import { expandTildePath } from "../utils/path.js";
28
31
 
29
32
  import type { PluginConfig, CodingKnowledgeConfig, CodingContext } from "../types.js";
30
33
  import { isCodingGraphInstalled } from "./optional-coding-graph.js";
34
+ import { displayErrorDetail } from "../runtime/better-sqlite.js";
31
35
  // Type-only reference to the surface context shape (ts-import-type rule).
32
36
  // Erased at compile time, so the runtime → surfaces → runtime import cycle is
33
37
  // type-only and has no runtime effect.
@@ -68,6 +72,16 @@ interface CodegraphModule {
68
72
  ): unknown;
69
73
  defaultCodingGitInvoker?: unknown;
70
74
  createCodingGraphEngine?(options?: unknown): { parseFile(input: unknown): Promise<unknown> };
75
+ // LSP resolution (issue #1917): present when @remnic/coding-graph ships
76
+ executeLspResolution?(requests: readonly unknown[], options: unknown): Promise<unknown>;
77
+ planLspUpgrades?(sites: readonly unknown[], budget: { maxRequests: number }): { requests: readonly unknown[]; budgetExhausted: number };
78
+ LspClient?: {
79
+ connect(options: {
80
+ launchSpec: { command: string; args: readonly string[] };
81
+ rootUri: string | null;
82
+ timeoutMs: number;
83
+ }): Promise<{ ok: true; client: unknown } | { ok: false; degradation: unknown }>;
84
+ };
71
85
  }
72
86
 
73
87
  /**
@@ -95,6 +109,21 @@ export interface CodegraphStore {
95
109
  upsertEdges?(
96
110
  edges: readonly CodegraphEdgeInput[],
97
111
  ): Promise<{ ok: true; persisted: number; skipped: number } | { ok: false; code: string }>;
112
+ /** Find the node whose span contains the byte offset (issue #1917). */
113
+ findNodeBySpan?(filePath: string, byteOffset: number): string | null;
114
+ /** Retire stale lsp-provenance edges for a file (issue #1895). */
115
+ reconcileLspEdges?(
116
+ filePath: string,
117
+ assertedEdges: ReadonlyArray<{ srcQualifiedName: string; dstQualifiedName: string; type: string }>,
118
+ ): number;
119
+ /** Callee names already CALLS-linked from a caller with a given provenance (issue #1917). */
120
+ resolvedCalleeNames?(srcQualifiedName: string, provenance: string, filePath?: string): string[];
121
+ /** Current CALLS edges of a provenance owned by a caller symbol in a file (issue #1917). */
122
+ callEdgesForCaller?(
123
+ srcQualifiedName: string,
124
+ filePath: string,
125
+ provenance: string,
126
+ ): Array<{ srcQualifiedName: string; dstQualifiedName: string; dstName: string; type: string }>;
98
127
  }
99
128
 
100
129
  /**
@@ -470,13 +499,14 @@ export async function closeAllCodegraphStores(): Promise<void> {
470
499
 
471
500
  export function makeCodegraphRuntimeDelegates(): Pick<
472
501
  CodegraphSurfaceContext,
473
- "runReindex" | "reportIndexStatus" | "detectChanges" | "ingestTraces"
502
+ "runReindex" | "runLspResolution" | "reportIndexStatus" | "detectChanges" | "ingestTraces"
474
503
  > {
475
504
  return {
476
505
  runReindex: (store, repoRoot, mode) => runCodegraphReindex({ store, repoRoot, mode }),
477
506
  reportIndexStatus: (store, repoRoot) => reportCodegraphIndexStatus({ store, repoRoot }),
478
507
  detectChanges: (store, repoRoot, head) => detectCodegraphChanges({ store, repoRoot, head }),
479
508
  ingestTraces: (store, traces) => ingestCodegraphTraces({ store, traces }),
509
+ runLspResolution: (store, repoRoot, lspConfig) => runCodegraphLspResolution({ store, repoRoot, lspConfig }),
480
510
  };
481
511
  }
482
512
 
@@ -566,6 +596,514 @@ export async function runCodegraphReindex(params: {
566
596
  }
567
597
  }
568
598
 
599
+
600
+ /**
601
+ * True when a tracked path resolves — via a symlinked file or any symlinked
602
+ * parent — outside repoRoot. Mirrors the reindex path's guard (reindex.ts
603
+ * `symlinkEscapesRoot`, rule 3): without it, a tracked `*.ts` symlink to a
604
+ * private file outside the checkout would be parsed and sent to a language
605
+ * server. A realpath error returns false so the normal read path surfaces
606
+ * the natural failure.
607
+ */
608
+ async function lspPathEscapesRoot(repoRoot: string, absPath: string): Promise<boolean> {
609
+ try {
610
+ const [realRoot, realAbs] = await Promise.all([realpath(repoRoot), realpath(absPath)]);
611
+ const rel = path.relative(realRoot, realAbs);
612
+ return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
613
+ } catch {
614
+ return false;
615
+ }
616
+ }
617
+
618
+ /** Sync variant for the executor's resolveContent seam. */
619
+ function lspPathEscapesRootSync(repoRoot: string, absPath: string): boolean {
620
+ try {
621
+ const realRoot = realpathSync(repoRoot);
622
+ const realAbs = realpathSync(absPath);
623
+ const rel = path.relative(realRoot, realAbs);
624
+ return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
625
+ } catch {
626
+ return false;
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Default LSP server launch specs per language. The config's
632
+ * `codingKnowledge.lsp.servers` overrides these; a language with neither
633
+ * an override nor a default is skipped (degradation, not an error).
634
+ */
635
+ const DEFAULT_LSP_SERVERS: Record<string, { command: string; args: string[] }> = {
636
+ typescript: { command: "typescript-language-server", args: ["--stdio"] },
637
+ tsx: { command: "typescript-language-server", args: ["--stdio"] },
638
+ javascript: { command: "typescript-language-server", args: ["--stdio"] },
639
+ python: { command: "pyright-langserver", args: ["--stdio"] },
640
+ go: { command: "gopls", args: [] },
641
+ rust: { command: "rust-analyzer", args: [] },
642
+ };
643
+
644
+ /** One gathered candidate call site (pre-planner shape). */
645
+ interface GatheredLspSite {
646
+ filePath: string;
647
+ language: string;
648
+ content: string;
649
+ calleeByteOffset: number;
650
+ calleeName: string;
651
+ srcQualifiedName: string;
652
+ }
653
+
654
+ /**
655
+ * Run LSP type resolution after a heuristic reindex (issue #1917).
656
+ * Re-parses tracked files, gathers call sites Phase A left unresolved
657
+ * (member accesses plus bare calls with no existing CALLS edge), groups
658
+ * them by language, and per language connects a real language server via
659
+ * `LspClient.connect` and invokes `executeLspResolution`.
660
+ *
661
+ * Degrades cleanly at every seam: missing package/functions, no language
662
+ * server for a language, or a failed handshake all leave the heuristic
663
+ * edges standing (soft-fail, mirrors #1894's documented posture).
664
+ */
665
+ export async function runCodegraphLspResolution(params: {
666
+ readonly store: CodegraphStore;
667
+ readonly repoRoot: string;
668
+ readonly lspConfig: NonNullable<CodingKnowledgeConfig["lsp"]>;
669
+ }): Promise<
670
+ | {
671
+ ok: true;
672
+ upgraded: number;
673
+ unresolved: number;
674
+ budgetExhausted: number;
675
+ degradations?: Array<{ language: string; code: string; message: string }>;
676
+ }
677
+ | { ok: false; code: string; message: string }
678
+ > {
679
+ const mod = await loadCodegraphModule();
680
+ if (mod === null) {
681
+ return { ok: false, code: "package_missing", message: "@remnic/coding-graph is not installed." };
682
+ }
683
+ if (
684
+ typeof mod.executeLspResolution !== "function" ||
685
+ typeof mod.planLspUpgrades !== "function" ||
686
+ typeof mod.LspClient?.connect !== "function"
687
+ ) {
688
+ return { ok: false, code: "runtime_unavailable", message: "LSP resolution is not available in the installed @remnic/coding-graph." };
689
+ }
690
+ const engine = mod.createCodingGraphEngine?.() ?? null;
691
+ if (engine === null) {
692
+ return { ok: false, code: "engine_unavailable", message: "createCodingGraphEngine is not available." };
693
+ }
694
+ // Resolve to an absolute root up front: the executor builds file://
695
+ // URIs by joining workspaceRoot, and a relative root (".") would yield
696
+ // malformed URIs like file://src/foo.ts (review thread). The reindex
697
+ // path resolves relative cwd values the same way.
698
+ const repoRoot = path.resolve(params.repoRoot);
699
+ const gitFactory = mod.defaultCodingGitInvoker as
700
+ | (() => { listTrackedFiles(cwd: string): { ok: true; paths: readonly string[] } | { ok: false; code: string } })
701
+ | undefined;
702
+ if (typeof gitFactory !== "function") {
703
+ return { ok: true, upgraded: 0, unresolved: 0, budgetExhausted: 0 };
704
+ }
705
+ const tracked = gitFactory().listTrackedFiles(repoRoot);
706
+ if (!("paths" in tracked)) {
707
+ return { ok: true, upgraded: 0, unresolved: 0, budgetExhausted: 0 };
708
+ }
709
+
710
+ // Memoized per-caller lookup of already-resolved callee names: a bare
711
+ // call whose (src)-[CALLS]->(dst.name == callee) edge already exists was
712
+ // resolved by Phase A and must not consume LSP budget (review thread:
713
+ // budget starvation). Member accesses are Phase A's deliberate skips —
714
+ // always candidates.
715
+ // Provenance matters: only HEURISTIC edges mark a site as Phase-A
716
+ // resolved. A site whose only edge is a prior "lsp" edge MUST be
717
+ // re-planned — excluding it from the plan while reconciling the file
718
+ // would retire that still-valid lsp edge (review thread). When the
719
+ // installed store predates resolvedCalleeNames, nothing is treated as
720
+ // resolved (conservative: spends budget, never mis-retires).
721
+ // Memo key includes the caller FILE: duplicate top-level qualified
722
+ // names across files must not leak each other's resolutions.
723
+ const resolvedCalleesBySrc = new Map<string, Set<string>>();
724
+ const alreadyResolved = (filePath: string, srcQualifiedName: string, calleeName: string): boolean => {
725
+ const key = `${filePath}\u0000${srcQualifiedName}`;
726
+ let names = resolvedCalleesBySrc.get(key);
727
+ if (names === undefined) {
728
+ names = new Set<string>();
729
+ if (typeof params.store.resolvedCalleeNames === "function") {
730
+ for (const name of params.store.resolvedCalleeNames(srcQualifiedName, "heuristic", filePath)) {
731
+ names.add(name);
732
+ }
733
+ }
734
+ resolvedCalleesBySrc.set(key, names);
735
+ }
736
+ return names.has(calleeName);
737
+ };
738
+
739
+ // Gather unresolved call sites from the freshly indexed source.
740
+ const sites: GatheredLspSite[] = [];
741
+ const gatheredCountByFile = new Map<string, number>();
742
+ // Sites excluded because Phase A already resolved them (heuristic edge).
743
+ // A file with ANY filtered site must not reconcile: those sites are not
744
+ // in the asserted set, so reconciliation would retire their prior lsp
745
+ // edges even though the sites were never re-queried (review thread).
746
+ const filteredCountByFile = new Map<string, number>();
747
+ // Caller symbols of filtered sites per file — their CURRENT lsp edges
748
+ // are appended to the asserted set at reconcile time so stale cleanup
749
+ // still runs for the rest of the file without dropping them.
750
+ const filteredSrcByFile = new Map<string, Map<string, Set<string>>>();
751
+ // Files that parsed cleanly this run — the safe candidates for the
752
+ // empty-set reconcile below.
753
+ const parsedFiles = new Set<string>();
754
+ for (const rel of tracked.paths) {
755
+ const absPath = path.join(repoRoot, rel);
756
+ // Same symlink containment the reindex path enforces (rule 3).
757
+ if (await lspPathEscapesRoot(repoRoot, absPath)) continue;
758
+ let content: string;
759
+ try {
760
+ content = await readFile(absPath, "utf-8");
761
+ } catch {
762
+ continue;
763
+ }
764
+ let parsed: unknown;
765
+ try {
766
+ parsed = await engine.parseFile({ path: rel, content });
767
+ } catch {
768
+ continue;
769
+ }
770
+ if (!parsed || typeof parsed !== "object" || !("ok" in parsed) || parsed.ok !== true || !("ir" in parsed)) continue;
771
+ const ir = parsed.ir;
772
+ if (!ir || typeof ir !== "object" || !("callSites" in ir) || !Array.isArray(ir.callSites)) continue;
773
+ parsedFiles.add(rel);
774
+ const language = "language" in ir && typeof ir.language === "string" ? ir.language : "";
775
+ const symbols = "symbols" in ir && Array.isArray(ir.symbols) ? ir.symbols : [];
776
+ // UTF-8 view of the source for byte-exact callee-name searches.
777
+ const contentBytes = Buffer.from(content, "utf-8");
778
+ for (const site of ir.callSites) {
779
+ if (!site || typeof site !== "object" || !("span" in site)) continue;
780
+ const span = site.span;
781
+ if (!span || typeof span !== "object" || !("startByte" in span) || typeof span.startByte !== "number") continue;
782
+ const calleeName =
783
+ "calleeNameCandidates" in site && Array.isArray(site.calleeNameCandidates) && typeof site.calleeNameCandidates[0] === "string"
784
+ ? site.calleeNameCandidates[0]
785
+ : "";
786
+ if (calleeName.length === 0) continue;
787
+ // Innermost enclosing symbol = SMALLEST containing span (review
788
+ // thread: first-match picks the outermost container for nested
789
+ // symbols, attaching edges to the wrong caller).
790
+ let srcQualifiedName = "";
791
+ let bestSpanSize = Number.POSITIVE_INFINITY;
792
+ for (const sym of symbols) {
793
+ if (!sym || typeof sym !== "object" || !("span" in sym) || !("qualifiedName" in sym)) continue;
794
+ const ss = sym.span;
795
+ if (!ss || typeof ss !== "object" || !("startByte" in ss) || !("endByte" in ss)) continue;
796
+ if (typeof ss.startByte !== "number" || typeof ss.endByte !== "number") continue;
797
+ if (ss.startByte <= span.startByte && span.startByte < ss.endByte) {
798
+ const size = ss.endByte - ss.startByte;
799
+ if (size < bestSpanSize) {
800
+ bestSpanSize = size;
801
+ srcQualifiedName = typeof sym.qualifiedName === "string" ? sym.qualifiedName : "";
802
+ }
803
+ }
804
+ }
805
+ if (srcQualifiedName.length === 0) continue;
806
+ const memberAccess = "memberAccess" in site && site.memberAccess === true;
807
+ if (!memberAccess && alreadyResolved(rel, srcQualifiedName, calleeName)) {
808
+ filteredCountByFile.set(rel, (filteredCountByFile.get(rel) ?? 0) + 1);
809
+ let srcs = filteredSrcByFile.get(rel);
810
+ if (srcs === undefined) {
811
+ srcs = new Map<string, Set<string>>();
812
+ filteredSrcByFile.set(rel, srcs);
813
+ }
814
+ const callees = srcs.get(srcQualifiedName);
815
+ if (callees === undefined) srcs.set(srcQualifiedName, new Set([calleeName]));
816
+ else callees.add(calleeName);
817
+ continue;
818
+ }
819
+ // Position the definition query on the callee NAME, not the call
820
+ // expression start (member calls: `obj.save()` must query at `save`).
821
+ // The search runs in Buffer space because span offsets are UTF-8
822
+ // BYTES while string indexOf returns UTF-16 code units — non-ASCII
823
+ // source before the callee would skew a string-index search (review
824
+ // thread). Bounded to this call site's span (parser rule 20).
825
+ const nameIdx = contentBytes.indexOf(Buffer.from(calleeName, "utf-8"), span.startByte);
826
+ const endByte = "endByte" in span && typeof span.endByte === "number" ? span.endByte : span.startByte + calleeName.length;
827
+ const calleeByteOffset = nameIdx >= 0 && nameIdx < endByte ? nameIdx : span.startByte;
828
+ sites.push({ filePath: rel, language, content, calleeByteOffset, calleeName, srcQualifiedName });
829
+ gatheredCountByFile.set(rel, (gatheredCountByFile.get(rel) ?? 0) + 1);
830
+ }
831
+ }
832
+ // Retire stale lsp edges for files that parsed cleanly and have NO LSP
833
+ // candidates at all this run (e.g. a previously upgraded member call was
834
+ // deleted while its containing function remains). The heuristic reindex
835
+ // deliberately keeps lsp-provenance rows out of its stale-delete scope,
836
+ // so this pass is the only owner of that cleanup (review thread). Files
837
+ // with filtered (heuristic-resolved) sites are left alone — their prior
838
+ // lsp edges were not re-queried.
839
+ // Filtered (Phase-A-resolved) sites keep their CURRENT lsp edges by
840
+ // asserting them explicitly — reconciliation can then retire genuinely
841
+ // stale edges in the same file without dropping preserved ones. When
842
+ // the store predates callEdgesForCaller, preservation is impossible,
843
+ // so callers below fall back to skipping reconcile for such files.
844
+ const canPreserve = typeof params.store.callEdgesForCaller === "function";
845
+ const preservedEdgesForFile = (
846
+ rel: string,
847
+ ): Array<{ srcQualifiedName: string; dstQualifiedName: string; type: string }> => {
848
+ const srcs = filteredSrcByFile.get(rel);
849
+ if (srcs === undefined || !canPreserve) return [];
850
+ const preserved: Array<{ srcQualifiedName: string; dstQualifiedName: string; type: string }> = [];
851
+ for (const [src] of srcs) {
852
+ // Preserve ONLY lsp edges that DUPLICATE a current heuristic
853
+ // resolution (same src, same dst) — the covering edge a filtered
854
+ // bare call still asserts today. A dstName-only match would also
855
+ // re-assert a REMOVED member call's edge when a same-named bare
856
+ // call stays filtered (review thread); requiring the exact
857
+ // heuristic dst retires those while keeping the live coverage.
858
+ // Cost: an lsp edge MORE precise than its heuristic twin (rare
859
+ // re-export chains) is retired too — conservative, the heuristic
860
+ // edge still represents the call.
861
+ const heuristicDsts = new Set(
862
+ params.store.callEdgesForCaller!(src, rel, "heuristic").map((e) => e.dstQualifiedName),
863
+ );
864
+ for (const edge of params.store.callEdgesForCaller!(src, rel, "lsp")) {
865
+ if (!heuristicDsts.has(edge.dstQualifiedName)) continue;
866
+ preserved.push({
867
+ srcQualifiedName: edge.srcQualifiedName,
868
+ dstQualifiedName: edge.dstQualifiedName,
869
+ type: edge.type,
870
+ });
871
+ }
872
+ }
873
+ return preserved;
874
+ };
875
+ if (typeof params.store.reconcileLspEdges === "function") {
876
+ for (const rel of parsedFiles) {
877
+ if ((gatheredCountByFile.get(rel) ?? 0) !== 0) continue;
878
+ const filteredCount = filteredCountByFile.get(rel) ?? 0;
879
+ if (filteredCount > 0 && !canPreserve) continue;
880
+ params.store.reconcileLspEdges(rel, preservedEdgesForFile(rel));
881
+ }
882
+ }
883
+ if (sites.length === 0) {
884
+ return { ok: true, upgraded: 0, unresolved: 0, budgetExhausted: 0 };
885
+ }
886
+
887
+ // Group by language; each group gets its own server connection. The
888
+ // budget is shared across groups (maxRequestsPerRun is per RUN).
889
+ const byLanguage = new Map<string, GatheredLspSite[]>();
890
+ // Caller symbol -> FILES, for attributing apply-time skips to the file
891
+ // batches they may belong to (the apply callback sees upgrades, not
892
+ // files). A SET because duplicate caller qualified names can exist in
893
+ // multiple tracked files — a skip conservatively suppresses reconcile
894
+ // in every file that could own the skipped edge (review thread: a
895
+ // single-file map was overwritten by the later file).
896
+ const srcToFiles = new Map<string, Set<string>>();
897
+ for (const site of sites) {
898
+ const group = byLanguage.get(site.language);
899
+ if (group === undefined) byLanguage.set(site.language, [site]);
900
+ else group.push(site);
901
+ const files = srcToFiles.get(site.srcQualifiedName);
902
+ if (files === undefined) srcToFiles.set(site.srcQualifiedName, new Set([site.filePath]));
903
+ else files.add(site.filePath);
904
+ }
905
+
906
+ const configServers = params.lspConfig.servers ?? {};
907
+ const timeoutMs = params.lspConfig.timeoutMs ?? 3000;
908
+ let remainingBudget = Math.max(1, Math.floor(params.lspConfig.maxRequestsPerRun ?? 500));
909
+ const rootUri = pathToFileURL(repoRoot).href;
910
+ // Cross-file definition targets need their bytes for position→offset
911
+ // conversion (review thread: without resolveContent, cross-file
912
+ // upgrades — the point of Phase B — stay unresolved).
913
+ const resolveContent = (filePath: string): string | null => {
914
+ const absPath = path.join(repoRoot, filePath);
915
+ if (lspPathEscapesRootSync(repoRoot, absPath)) return null;
916
+ try {
917
+ return readFileSync(absPath, "utf-8");
918
+ } catch {
919
+ return null;
920
+ }
921
+ };
922
+
923
+ let upgraded = 0;
924
+ let unresolved = 0;
925
+ let budgetExhausted = 0;
926
+ const degradations: Array<{ language: string; code: string; message: string }> = [];
927
+ for (const [language, group] of byLanguage) {
928
+ if (remainingBudget <= 0) {
929
+ budgetExhausted += group.length;
930
+ continue;
931
+ }
932
+ const rawSpec = configServers[language] ?? DEFAULT_LSP_SERVERS[language];
933
+ if (rawSpec === undefined) {
934
+ // A language with candidates but no launch spec is a DIAGNOSABLE
935
+ // gap, not ordinary unresolved calls (review thread): surface it so
936
+ // the operator knows to configure codingKnowledge.lsp.servers.
937
+ degradations.push({
938
+ language,
939
+ code: "no_launch_spec",
940
+ message: `No LSP server configured for ${language} (${group.length} candidate call site(s)); set codingKnowledge.lsp.servers.${language}.`,
941
+ });
942
+ unresolved += group.length;
943
+ continue;
944
+ }
945
+ const launchSpec = { command: rawSpec.command, args: rawSpec.args ?? [] };
946
+ const plan = mod.planLspUpgrades(group, { maxRequests: remainingBudget });
947
+ budgetExhausted += plan.budgetExhausted;
948
+ remainingBudget -= plan.requests.length;
949
+ if (plan.requests.length === 0) continue;
950
+ // Reconciliation is only safe for files whose gathered sites ALL made
951
+ // it into the plan — a budget-truncated file would otherwise retire
952
+ // valid lsp edges its omitted call sites still assert (review thread).
953
+ const plannedCountByFile = new Map<string, number>();
954
+ for (const req of plan.requests) {
955
+ if (req && typeof req === "object" && "filePath" in req && typeof req.filePath === "string") {
956
+ plannedCountByFile.set(req.filePath, (plannedCountByFile.get(req.filePath) ?? 0) + 1);
957
+ }
958
+ }
959
+ const connected = await mod.LspClient.connect({ launchSpec, rootUri, timeoutMs });
960
+ if (!connected.ok) {
961
+ // A missing/misconfigured server is a DIAGNOSABLE condition, not a
962
+ // silent count (review thread): record the degradation so
963
+ // codegraph_index can distinguish "server absent" from "LSP found
964
+ // no definitions".
965
+ const deg = connected.degradation;
966
+ const code =
967
+ deg && typeof deg === "object" && "code" in deg && typeof deg.code === "string" ? deg.code : "connect_failed";
968
+ const message =
969
+ deg && typeof deg === "object" && "detail" in deg && typeof deg.detail === "string"
970
+ ? deg.detail
971
+ : `LSP server for ${language} failed to connect.`;
972
+ degradations.push({ language, code, message });
973
+ unresolved += plan.requests.length;
974
+ // The planned requests never reached a server — hand their budget
975
+ // back so a missing server for one language cannot starve the
976
+ // remaining language groups (review thread).
977
+ remainingBudget += plan.requests.length;
978
+ continue;
979
+ }
980
+ const client = connected.client;
981
+ // Skip tracking keyed by FILE (via the upgrades' caller symbols):
982
+ // a skip must (a) not count as an upgrade and (b) suppress
983
+ // reconciliation for exactly the file batch that observed it — a
984
+ // boolean flag went stale whenever the executor skipped a batch's
985
+ // reconcile (non-exhaustive) or never called apply (zero upgrades)
986
+ // (review threads).
987
+ let skippedEdgesTotal = 0;
988
+ const skippedFiles = new Set<string>();
989
+ try {
990
+ const result = await mod.executeLspResolution(plan.requests, {
991
+ client,
992
+ workspaceRoot: repoRoot,
993
+ resolveContent,
994
+ nodeLocator: (filePath: string, byteOffset: number) =>
995
+ typeof params.store.findNodeBySpan === "function" ? params.store.findNodeBySpan(filePath, byteOffset) : null,
996
+ applyUpgrades: async (upgrades: readonly CodegraphEdgeInput[]) => {
997
+ if (typeof params.store.upsertEdges !== "function") {
998
+ throw new Error("store.upsertEdges is not available");
999
+ }
1000
+ const result = await params.store.upsertEdges(upgrades);
1001
+ // Throw ONLY when nothing persisted (ok:false) — the executor's
1002
+ // transactional contract holds because no state changed. A
1003
+ // skipped>0 result has ALREADY committed the resolvable edges in
1004
+ // one transaction, so throwing after the fact would desync the
1005
+ // executor's view from the DB (skipped reconciliation +
1006
+ // undercounted upgrades while edges persist — review thread).
1007
+ // Skips are surfaced as a degradation instead of a silent drop.
1008
+ if (!result.ok) {
1009
+ // The executor catches this throw and counts the batch as
1010
+ // unresolved — record the failure as a degradation FIRST so
1011
+ // a failed write (store_closed, SQLite error) is diagnosable
1012
+ // from the index summary, not indistinguishable from
1013
+ // "no definitions found" (review thread).
1014
+ degradations.push({
1015
+ language,
1016
+ code: "edge_write_failed",
1017
+ message: `upsertEdges failed for ${language}: ${result.code}.`,
1018
+ });
1019
+ throw new Error(`upsertEdges failed: ${result.code}`);
1020
+ }
1021
+ if (result.skipped > 0) {
1022
+ skippedEdgesTotal += result.skipped;
1023
+ for (const upgrade of upgrades) {
1024
+ for (const file of srcToFiles.get(upgrade.srcQualifiedName) ?? []) {
1025
+ skippedFiles.add(file);
1026
+ }
1027
+ }
1028
+ degradations.push({
1029
+ language,
1030
+ code: "edges_skipped",
1031
+ message: `upsertEdges persisted ${result.persisted} and skipped ${result.skipped} edge(s) for ${language} (unresolvable endpoints).`,
1032
+ });
1033
+ }
1034
+ },
1035
+ reconcileLspEdges: (
1036
+ filePath: string,
1037
+ assertedEdges: ReadonlyArray<{ srcQualifiedName: string; dstQualifiedName: string; type: string }>,
1038
+ ) => {
1039
+ if (typeof params.store.reconcileLspEdges !== "function") return;
1040
+ if (plannedCountByFile.get(filePath) !== gatheredCountByFile.get(filePath)) return;
1041
+ // This file's batch had skipped writes — its asserted set is
1042
+ // incomplete, so reconciling would retire edges that were never
1043
+ // re-asserted (see skip tracking above).
1044
+ if (skippedFiles.has(filePath)) return;
1045
+ // Heuristic-filtered sites were NOT re-queried: their current
1046
+ // lsp edges are appended to the asserted set so they survive
1047
+ // while stale edges for RE-QUERIED sites still get retired.
1048
+ // Without the preservation seam, skip (conservative).
1049
+ if ((filteredCountByFile.get(filePath) ?? 0) > 0 && !canPreserve) return;
1050
+ params.store.reconcileLspEdges(filePath, [...assertedEdges, ...preservedEdgesForFile(filePath)]);
1051
+ },
1052
+ });
1053
+ if (result && typeof result === "object" && "upgraded" in result && typeof result.upgraded === "number") {
1054
+ // The executor counts every upgrade it HANDED to applyUpgrades;
1055
+ // subtract the writes the store skipped so the reported number
1056
+ // reflects edges that actually persisted (review thread).
1057
+ upgraded += Math.max(0, result.upgraded - skippedEdgesTotal);
1058
+ unresolved += "unresolved" in result && typeof result.unresolved === "number" ? result.unresolved : 0;
1059
+ budgetExhausted += "budgetExhausted" in result && typeof result.budgetExhausted === "number" ? result.budgetExhausted : 0;
1060
+ // A mid-run server crash / protocol error returns counts PLUS a
1061
+ // degradation tag — dropping it would make the failure look like
1062
+ // ordinary unresolved calls (review thread).
1063
+ if ("degradation" in result && result.degradation && typeof result.degradation === "object") {
1064
+ const rd = result.degradation;
1065
+ degradations.push({
1066
+ language,
1067
+ code: "code" in rd && typeof rd.code === "string" ? rd.code : "lsp_degraded",
1068
+ message: "detail" in rd && typeof rd.detail === "string" ? rd.detail : `LSP pass for ${language} degraded mid-run.`,
1069
+ });
1070
+ }
1071
+ } else {
1072
+ unresolved += plan.requests.length;
1073
+ }
1074
+ } catch (err) {
1075
+ // Sanitized detail only (class name + Node error code) — this
1076
+ // message reaches client surfaces via result.lsp and must not leak
1077
+ // absolute paths or stack fragments (review thread; mirrors
1078
+ // displayErrorDetail's contract). The full error is recoverable
1079
+ // from server logs, not from the tool response.
1080
+ const detail = displayErrorDetail(err);
1081
+ return {
1082
+ ok: false,
1083
+ code: "lsp_resolution_error",
1084
+ message: detail.length > 0 ? `LSP resolution failed: ${detail}.` : "LSP resolution failed.",
1085
+ };
1086
+ } finally {
1087
+ const disposable = client as { dispose?: () => Promise<void> };
1088
+ if (typeof disposable.dispose === "function") {
1089
+ try {
1090
+ await disposable.dispose();
1091
+ } catch {
1092
+ // Disposal failure is not actionable — the child process is
1093
+ // reaped by the OS when the parent exits.
1094
+ }
1095
+ }
1096
+ }
1097
+ }
1098
+ return {
1099
+ ok: true,
1100
+ upgraded,
1101
+ unresolved,
1102
+ budgetExhausted,
1103
+ ...(degradations.length > 0 ? { degradations } : {}),
1104
+ };
1105
+ }
1106
+
569
1107
  /**
570
1108
  * Report index status via @remnic/coding-graph's getIndexStatus. Never throws
571
1109
  * — a git failure degrades to `mode: "git_unavailable"` in the status body.
@@ -878,3 +878,78 @@ test("resolveCodegraphDbPath: accepts an absolute codegraphDbDir", () => {
878
878
  assert.ok(p.startsWith("/var/lib/remnic/codegraph/"), p);
879
879
  assert.ok(p.endsWith(".sqlite"), p);
880
880
  });
881
+
882
+ // ──────────────────────────────────────────────────────────────────────────
883
+ // LSP Phase B wiring (issue #1917): handleIndex invokes runLspResolution
884
+ // after a successful reindex iff codingKnowledge.lsp.enabled — and LSP
885
+ // failure is non-fatal (heuristic edges stand).
886
+ // ──────────────────────────────────────────────────────────────────────────
887
+
888
+ function makeLspCtx(
889
+ lsp: { enabled: boolean } | undefined,
890
+ overrides: Partial<CodegraphSurfaceContext> = {},
891
+ ): CodegraphSurfaceContext {
892
+ const pluginConfig = {
893
+ codingKnowledge: { ...GATE_ON_CONFIG, lsp },
894
+ } as unknown as PluginConfig;
895
+ return makeEnabledCtx({ config: pluginConfig, ...overrides });
896
+ }
897
+
898
+ test("LSP wiring: handleIndex invokes runLspResolution after reindex when lsp.enabled", async () => {
899
+ const calls: Array<{ repoRoot: string; lspEnabled: boolean }> = [];
900
+ const ctx = makeLspCtx(
901
+ { enabled: true },
902
+ {
903
+ runReindex: async () => ({ ok: true, mode: "full", filesIngested: 2, head: "h1" }),
904
+ runLspResolution: async (_store, repoRoot, lspConfig) => {
905
+ calls.push({ repoRoot, lspEnabled: lspConfig.enabled });
906
+ return { ok: true, upgraded: 4, unresolved: 1, budgetExhausted: 0 };
907
+ },
908
+ },
909
+ );
910
+ const response = await handleCodegraphTool({ tool: "index", repoRoot: "/repo", mode: "auto" }, ctx);
911
+ assert.equal(response.ok, true);
912
+ assert.equal(calls.length, 1, "runLspResolution must be invoked exactly once");
913
+ assert.equal(calls[0]?.repoRoot, "/repo", "repoRoot forwards to the LSP pass");
914
+ assert.equal(calls[0]?.lspEnabled, true, "the parsed lsp config forwards");
915
+ if (!response.ok) throw new Error("expected ok");
916
+ const result = response.result as { lsp?: { upgraded: number; unresolved: number } };
917
+ assert.equal(result.lsp?.upgraded, 4, "LSP upgrade summary surfaces in the index result");
918
+ assert.equal(result.lsp?.unresolved, 1);
919
+ });
920
+
921
+ test("LSP wiring: handleIndex does NOT invoke runLspResolution when lsp is disabled or absent", async () => {
922
+ for (const lsp of [{ enabled: false }, undefined]) {
923
+ let called = false;
924
+ const ctx = makeLspCtx(lsp, {
925
+ runReindex: async () => ({ ok: true, mode: "full", filesIngested: 1, head: "h2" }),
926
+ runLspResolution: async () => {
927
+ called = true;
928
+ return { ok: true, upgraded: 0, unresolved: 0, budgetExhausted: 0 };
929
+ },
930
+ });
931
+ const response = await handleCodegraphTool({ tool: "index", repoRoot: "/repo", mode: "auto" }, ctx);
932
+ assert.equal(response.ok, true);
933
+ assert.equal(called, false, `lsp=${JSON.stringify(lsp)} must not trigger the LSP pass`);
934
+ if (!response.ok) throw new Error("expected ok");
935
+ const result = response.result as { lsp?: unknown };
936
+ assert.equal(result.lsp, undefined, "no LSP summary when the pass did not run");
937
+ }
938
+ });
939
+
940
+ test("LSP wiring: a failed LSP pass is non-fatal — index still reports ok with reindex stats", async () => {
941
+ const ctx = makeLspCtx(
942
+ { enabled: true },
943
+ {
944
+ runReindex: async () => ({ ok: true, mode: "incremental", filesIngested: 5, head: "h3" }),
945
+ runLspResolution: async () => ({ ok: false, code: "lsp_resolution_error", message: "server crashed" }),
946
+ },
947
+ );
948
+ const response = await handleCodegraphTool({ tool: "index", repoRoot: "/repo", mode: "auto" }, ctx);
949
+ assert.equal(response.ok, true, "LSP failure must not fail the index (heuristic edges stand)");
950
+ if (!response.ok) throw new Error("expected ok");
951
+ const result = response.result as { filesIngested: number; lsp?: { error?: string; message?: string } };
952
+ assert.equal(result.filesIngested, 5, "reindex stats survive the failed LSP pass");
953
+ assert.equal(result.lsp?.error, "lsp_resolution_error", "the degradation code surfaces (never silent)");
954
+ assert.equal(result.lsp?.message, "server crashed", "the degradation message surfaces");
955
+ });