seer-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. package/.vscode/settings.json +3 -0
  2. package/LICENSE +176 -0
  3. package/README.md +272 -0
  4. package/README_dev.md +199 -0
  5. package/dist/bundle/ci.d.ts +47 -0
  6. package/dist/bundle/ci.d.ts.map +1 -0
  7. package/dist/bundle/ci.js +113 -0
  8. package/dist/bundle/ci.js.map +1 -0
  9. package/dist/bundle/contract.d.ts +111 -0
  10. package/dist/bundle/contract.d.ts.map +1 -0
  11. package/dist/bundle/contract.js +352 -0
  12. package/dist/bundle/contract.js.map +1 -0
  13. package/dist/bundle/export.d.ts +36 -0
  14. package/dist/bundle/export.d.ts.map +1 -0
  15. package/dist/bundle/export.js +152 -0
  16. package/dist/bundle/export.js.map +1 -0
  17. package/dist/bundle/external.d.ts +66 -0
  18. package/dist/bundle/external.d.ts.map +1 -0
  19. package/dist/bundle/external.js +238 -0
  20. package/dist/bundle/external.js.map +1 -0
  21. package/dist/bundle/format.d.ts +94 -0
  22. package/dist/bundle/format.d.ts.map +1 -0
  23. package/dist/bundle/format.js +42 -0
  24. package/dist/bundle/format.js.map +1 -0
  25. package/dist/bundle/import.d.ts +49 -0
  26. package/dist/bundle/import.d.ts.map +1 -0
  27. package/dist/bundle/import.js +116 -0
  28. package/dist/bundle/import.js.map +1 -0
  29. package/dist/cli/index.d.ts +3 -0
  30. package/dist/cli/index.d.ts.map +1 -0
  31. package/dist/cli/index.js +1402 -0
  32. package/dist/cli/index.js.map +1 -0
  33. package/dist/cli/init.d.ts +48 -0
  34. package/dist/cli/init.d.ts.map +1 -0
  35. package/dist/cli/init.js +284 -0
  36. package/dist/cli/init.js.map +1 -0
  37. package/dist/db/schema.d.ts +3 -0
  38. package/dist/db/schema.d.ts.map +1 -0
  39. package/dist/db/schema.js +616 -0
  40. package/dist/db/schema.js.map +1 -0
  41. package/dist/db/store.d.ts +1011 -0
  42. package/dist/db/store.d.ts.map +1 -0
  43. package/dist/db/store.js +3888 -0
  44. package/dist/db/store.js.map +1 -0
  45. package/dist/graph/pagerank.d.ts +9 -0
  46. package/dist/graph/pagerank.d.ts.map +1 -0
  47. package/dist/graph/pagerank.js +47 -0
  48. package/dist/graph/pagerank.js.map +1 -0
  49. package/dist/indexer/architecture.d.ts +72 -0
  50. package/dist/indexer/architecture.d.ts.map +1 -0
  51. package/dist/indexer/architecture.js +112 -0
  52. package/dist/indexer/architecture.js.map +1 -0
  53. package/dist/indexer/behavior.d.ts +75 -0
  54. package/dist/indexer/behavior.d.ts.map +1 -0
  55. package/dist/indexer/behavior.js +395 -0
  56. package/dist/indexer/behavior.js.map +1 -0
  57. package/dist/indexer/boundaries.d.ts +60 -0
  58. package/dist/indexer/boundaries.d.ts.map +1 -0
  59. package/dist/indexer/boundaries.js +366 -0
  60. package/dist/indexer/boundaries.js.map +1 -0
  61. package/dist/indexer/churn.d.ts +15 -0
  62. package/dist/indexer/churn.d.ts.map +1 -0
  63. package/dist/indexer/churn.js +49 -0
  64. package/dist/indexer/churn.js.map +1 -0
  65. package/dist/indexer/classify.d.ts +9 -0
  66. package/dist/indexer/classify.d.ts.map +1 -0
  67. package/dist/indexer/classify.js +90 -0
  68. package/dist/indexer/classify.js.map +1 -0
  69. package/dist/indexer/context.d.ts +176 -0
  70. package/dist/indexer/context.d.ts.map +1 -0
  71. package/dist/indexer/context.js +193 -0
  72. package/dist/indexer/context.js.map +1 -0
  73. package/dist/indexer/continuity.d.ts +67 -0
  74. package/dist/indexer/continuity.d.ts.map +1 -0
  75. package/dist/indexer/continuity.js +288 -0
  76. package/dist/indexer/continuity.js.map +1 -0
  77. package/dist/indexer/detectchanges.d.ts +32 -0
  78. package/dist/indexer/detectchanges.d.ts.map +1 -0
  79. package/dist/indexer/detectchanges.js +74 -0
  80. package/dist/indexer/detectchanges.js.map +1 -0
  81. package/dist/indexer/discovery.d.ts +37 -0
  82. package/dist/indexer/discovery.d.ts.map +1 -0
  83. package/dist/indexer/discovery.js +136 -0
  84. package/dist/indexer/discovery.js.map +1 -0
  85. package/dist/indexer/externaldeps.d.ts +18 -0
  86. package/dist/indexer/externaldeps.d.ts.map +1 -0
  87. package/dist/indexer/externaldeps.js +288 -0
  88. package/dist/indexer/externaldeps.js.map +1 -0
  89. package/dist/indexer/freshness.d.ts +48 -0
  90. package/dist/indexer/freshness.d.ts.map +1 -0
  91. package/dist/indexer/freshness.js +128 -0
  92. package/dist/indexer/freshness.js.map +1 -0
  93. package/dist/indexer/git.d.ts +144 -0
  94. package/dist/indexer/git.d.ts.map +1 -0
  95. package/dist/indexer/git.js +444 -0
  96. package/dist/indexer/git.js.map +1 -0
  97. package/dist/indexer/index.d.ts +145 -0
  98. package/dist/indexer/index.d.ts.map +1 -0
  99. package/dist/indexer/index.js +930 -0
  100. package/dist/indexer/index.js.map +1 -0
  101. package/dist/indexer/modules.d.ts +62 -0
  102. package/dist/indexer/modules.d.ts.map +1 -0
  103. package/dist/indexer/modules.js +293 -0
  104. package/dist/indexer/modules.js.map +1 -0
  105. package/dist/indexer/preflight.d.ts +154 -0
  106. package/dist/indexer/preflight.d.ts.map +1 -0
  107. package/dist/indexer/preflight.js +399 -0
  108. package/dist/indexer/preflight.js.map +1 -0
  109. package/dist/indexer/protoScanner.d.ts +34 -0
  110. package/dist/indexer/protoScanner.d.ts.map +1 -0
  111. package/dist/indexer/protoScanner.js +133 -0
  112. package/dist/indexer/protoScanner.js.map +1 -0
  113. package/dist/indexer/risk.d.ts +115 -0
  114. package/dist/indexer/risk.d.ts.map +1 -0
  115. package/dist/indexer/risk.js +194 -0
  116. package/dist/indexer/risk.js.map +1 -0
  117. package/dist/indexer/serviceHostScanner.d.ts +25 -0
  118. package/dist/indexer/serviceHostScanner.d.ts.map +1 -0
  119. package/dist/indexer/serviceHostScanner.js +95 -0
  120. package/dist/indexer/serviceHostScanner.js.map +1 -0
  121. package/dist/indexer/serviceLinks.d.ts +105 -0
  122. package/dist/indexer/serviceLinks.d.ts.map +1 -0
  123. package/dist/indexer/serviceLinks.js +509 -0
  124. package/dist/indexer/serviceLinks.js.map +1 -0
  125. package/dist/indexer/shapehash.d.ts +98 -0
  126. package/dist/indexer/shapehash.d.ts.map +1 -0
  127. package/dist/indexer/shapehash.js +354 -0
  128. package/dist/indexer/shapehash.js.map +1 -0
  129. package/dist/indexer/skeleton.d.ts +15 -0
  130. package/dist/indexer/skeleton.d.ts.map +1 -0
  131. package/dist/indexer/skeleton.js +136 -0
  132. package/dist/indexer/skeleton.js.map +1 -0
  133. package/dist/indexer/symbolhistory.d.ts +41 -0
  134. package/dist/indexer/symbolhistory.d.ts.map +1 -0
  135. package/dist/indexer/symbolhistory.js +124 -0
  136. package/dist/indexer/symbolhistory.js.map +1 -0
  137. package/dist/indexer/watcher.d.ts +68 -0
  138. package/dist/indexer/watcher.d.ts.map +1 -0
  139. package/dist/indexer/watcher.js +179 -0
  140. package/dist/indexer/watcher.js.map +1 -0
  141. package/dist/mcp/server.d.ts +80 -0
  142. package/dist/mcp/server.d.ts.map +1 -0
  143. package/dist/mcp/server.js +1610 -0
  144. package/dist/mcp/server.js.map +1 -0
  145. package/dist/parser/index.d.ts +8 -0
  146. package/dist/parser/index.d.ts.map +1 -0
  147. package/dist/parser/index.js +33 -0
  148. package/dist/parser/index.js.map +1 -0
  149. package/dist/parser/languages/cpp.d.ts +3 -0
  150. package/dist/parser/languages/cpp.d.ts.map +1 -0
  151. package/dist/parser/languages/cpp.js +350 -0
  152. package/dist/parser/languages/cpp.js.map +1 -0
  153. package/dist/parser/languages/csharp.d.ts +3 -0
  154. package/dist/parser/languages/csharp.d.ts.map +1 -0
  155. package/dist/parser/languages/csharp.js +239 -0
  156. package/dist/parser/languages/csharp.js.map +1 -0
  157. package/dist/parser/languages/go.d.ts +3 -0
  158. package/dist/parser/languages/go.d.ts.map +1 -0
  159. package/dist/parser/languages/go.js +259 -0
  160. package/dist/parser/languages/go.js.map +1 -0
  161. package/dist/parser/languages/java.d.ts +3 -0
  162. package/dist/parser/languages/java.d.ts.map +1 -0
  163. package/dist/parser/languages/java.js +391 -0
  164. package/dist/parser/languages/java.js.map +1 -0
  165. package/dist/parser/languages/python.d.ts +3 -0
  166. package/dist/parser/languages/python.d.ts.map +1 -0
  167. package/dist/parser/languages/python.js +396 -0
  168. package/dist/parser/languages/python.js.map +1 -0
  169. package/dist/parser/languages/rust.d.ts +3 -0
  170. package/dist/parser/languages/rust.d.ts.map +1 -0
  171. package/dist/parser/languages/rust.js +159 -0
  172. package/dist/parser/languages/rust.js.map +1 -0
  173. package/dist/parser/languages/typescript.d.ts +3 -0
  174. package/dist/parser/languages/typescript.d.ts.map +1 -0
  175. package/dist/parser/languages/typescript.js +1442 -0
  176. package/dist/parser/languages/typescript.js.map +1 -0
  177. package/dist/parser/parserContext.d.ts +77 -0
  178. package/dist/parser/parserContext.d.ts.map +1 -0
  179. package/dist/parser/parserContext.js +354 -0
  180. package/dist/parser/parserContext.js.map +1 -0
  181. package/dist/parser/walker.d.ts +81 -0
  182. package/dist/parser/walker.d.ts.map +1 -0
  183. package/dist/parser/walker.js +217 -0
  184. package/dist/parser/walker.js.map +1 -0
  185. package/dist/parser/worker.d.ts +66 -0
  186. package/dist/parser/worker.d.ts.map +1 -0
  187. package/dist/parser/worker.js +129 -0
  188. package/dist/parser/worker.js.map +1 -0
  189. package/dist/parser/workerpool.d.ts +107 -0
  190. package/dist/parser/workerpool.d.ts.map +1 -0
  191. package/dist/parser/workerpool.js +383 -0
  192. package/dist/parser/workerpool.js.map +1 -0
  193. package/dist/scip/format.d.ts +87 -0
  194. package/dist/scip/format.d.ts.map +1 -0
  195. package/dist/scip/format.js +31 -0
  196. package/dist/scip/format.js.map +1 -0
  197. package/dist/scip/import.d.ts +37 -0
  198. package/dist/scip/import.d.ts.map +1 -0
  199. package/dist/scip/import.js +180 -0
  200. package/dist/scip/import.js.map +1 -0
  201. package/dist/types.d.ts +392 -0
  202. package/dist/types.d.ts.map +1 -0
  203. package/dist/types.js +4 -0
  204. package/dist/types.js.map +1 -0
  205. package/docs/architecture.md +105 -0
  206. package/docs/benchmarks/methodology.md +134 -0
  207. package/docs/benchmarks/raw-results.md +71 -0
  208. package/docs/benchmarks.md +74 -0
  209. package/docs/cli.md +148 -0
  210. package/docs/examples/behavior-tests.md +70 -0
  211. package/docs/examples/change-history.md +85 -0
  212. package/docs/examples/pre-edit-context.md +81 -0
  213. package/docs/examples/service-links.md +88 -0
  214. package/docs/examples.md +80 -0
  215. package/docs/faq.md +70 -0
  216. package/docs/internals.md +104 -0
  217. package/docs/languages.md +70 -0
  218. package/docs/limits.md +52 -0
  219. package/docs/mcp.md +199 -0
  220. package/docs/quickstart.md +119 -0
  221. package/docs/testing.md +123 -0
  222. package/docs/tools.md +115 -0
  223. package/package.json +52 -0
  224. package/research-codebase.md +578 -0
  225. package/seer-cli-docs.md +326 -0
  226. package/seer-master-guide.md +246 -0
  227. package/src/bundle/ci.ts +141 -0
  228. package/src/bundle/contract.ts +387 -0
  229. package/src/bundle/export.ts +175 -0
  230. package/src/bundle/external.ts +285 -0
  231. package/src/bundle/format.ts +92 -0
  232. package/src/bundle/import.ts +157 -0
  233. package/src/cli/index.ts +1249 -0
  234. package/src/cli/init.ts +389 -0
  235. package/src/db/schema.ts +614 -0
  236. package/src/db/store.ts +4306 -0
  237. package/src/graph/pagerank.ts +53 -0
  238. package/src/indexer/architecture.ts +148 -0
  239. package/src/indexer/behavior.ts +466 -0
  240. package/src/indexer/boundaries.ts +374 -0
  241. package/src/indexer/churn.ts +58 -0
  242. package/src/indexer/classify.ts +96 -0
  243. package/src/indexer/context.ts +340 -0
  244. package/src/indexer/continuity.ts +322 -0
  245. package/src/indexer/detectchanges.ts +94 -0
  246. package/src/indexer/discovery.ts +176 -0
  247. package/src/indexer/externaldeps.ts +243 -0
  248. package/src/indexer/freshness.ts +166 -0
  249. package/src/indexer/git.ts +453 -0
  250. package/src/indexer/index.ts +1092 -0
  251. package/src/indexer/modules.ts +358 -0
  252. package/src/indexer/preflight.ts +548 -0
  253. package/src/indexer/protoScanner.ts +147 -0
  254. package/src/indexer/risk.ts +304 -0
  255. package/src/indexer/serviceHostScanner.ts +92 -0
  256. package/src/indexer/serviceLinks.ts +543 -0
  257. package/src/indexer/shapehash.ts +370 -0
  258. package/src/indexer/skeleton.ts +169 -0
  259. package/src/indexer/symbolhistory.ts +172 -0
  260. package/src/indexer/watcher.ts +206 -0
  261. package/src/mcp/server.ts +1659 -0
  262. package/src/parser/index.ts +37 -0
  263. package/src/parser/languages/cpp.ts +361 -0
  264. package/src/parser/languages/csharp.ts +235 -0
  265. package/src/parser/languages/go.ts +259 -0
  266. package/src/parser/languages/java.ts +382 -0
  267. package/src/parser/languages/python.ts +370 -0
  268. package/src/parser/languages/rust.ts +164 -0
  269. package/src/parser/languages/typescript.ts +1435 -0
  270. package/src/parser/parserContext.ts +392 -0
  271. package/src/parser/walker.ts +306 -0
  272. package/src/parser/worker.ts +181 -0
  273. package/src/parser/workerpool.ts +448 -0
  274. package/src/scip/format.ts +83 -0
  275. package/src/scip/import.ts +216 -0
  276. package/src/types.ts +457 -0
  277. package/tests/benchmark-service-links.ts +244 -0
  278. package/tests/bug-regressions.ts +626 -0
  279. package/tests/filters.ts +264 -0
  280. package/tests/fixtures/Counter.tsx +38 -0
  281. package/tests/fixtures/caller.ts +7 -0
  282. package/tests/fixtures/collisions.ts +23 -0
  283. package/tests/fixtures/local_helper.ts +5 -0
  284. package/tests/fixtures/overloads.java +17 -0
  285. package/tests/fixtures/remote_helper.ts +4 -0
  286. package/tests/fixtures/sample.c +15 -0
  287. package/tests/fixtures/sample.cpp +47 -0
  288. package/tests/fixtures/sample.cs +62 -0
  289. package/tests/fixtures/sample.go +68 -0
  290. package/tests/fixtures/sample.h +30 -0
  291. package/tests/fixtures/sample.java +85 -0
  292. package/tests/fixtures/sample.py +46 -0
  293. package/tests/fixtures/sample.rs +78 -0
  294. package/tests/fixtures/sample.ts +76 -0
  295. package/tests/fixtures-service/HttpClients.cs +30 -0
  296. package/tests/fixtures-service/HttpClients.java +24 -0
  297. package/tests/fixtures-service/billing.ts +15 -0
  298. package/tests/fixtures-service/docker-compose.yml +15 -0
  299. package/tests/fixtures-service/gateway.ts +10 -0
  300. package/tests/fixtures-service/get_user.ts +11 -0
  301. package/tests/fixtures-service/graphql_client.ts +63 -0
  302. package/tests/fixtures-service/graphql_server.ts +30 -0
  303. package/tests/fixtures-service/grpc_client.go +30 -0
  304. package/tests/fixtures-service/http_clients.go +23 -0
  305. package/tests/fixtures-service/http_clients.py +38 -0
  306. package/tests/fixtures-service/http_clients.ts +49 -0
  307. package/tests/fixtures-service/k8s/payment-service.yaml +22 -0
  308. package/tests/fixtures-service/k8s_calls.ts +20 -0
  309. package/tests/fixtures-service/messaging.ts +87 -0
  310. package/tests/fixtures-service/trpc_client.ts +39 -0
  311. package/tests/fixtures-service/trpc_server.ts +39 -0
  312. package/tests/fixtures-service/user_service.proto +33 -0
  313. package/tests/fixtures-trackcd/Cargo.toml +11 -0
  314. package/tests/fixtures-trackcd/SpringController.java +36 -0
  315. package/tests/fixtures-trackcd/auth_service.ts +19 -0
  316. package/tests/fixtures-trackcd/complex_module.py +50 -0
  317. package/tests/fixtures-trackcd/express_app.js +30 -0
  318. package/tests/fixtures-trackcd/fastapi_app.py +49 -0
  319. package/tests/fixtures-trackcd/fastify_object_routes.js +32 -0
  320. package/tests/fixtures-trackcd/go.mod +8 -0
  321. package/tests/fixtures-trackcd/package.json +15 -0
  322. package/tests/fixtures-trackcd/requirements.txt +4 -0
  323. package/tests/fixtures-trackcd/tests/auth_service.test.ts +13 -0
  324. package/tests/fixtures-tracke/auth/AuthService.ts +23 -0
  325. package/tests/fixtures-tracke/auth/crypto.ts +7 -0
  326. package/tests/fixtures-tracke/billing/Billing.ts +20 -0
  327. package/tests/fixtures-tracke/billing/Invoice.ts +10 -0
  328. package/tests/fixtures-tracke/billing/server.ts +17 -0
  329. package/tests/fixtures-tracke/package.json +7 -0
  330. package/tests/fixtures-tracke/tests/auth.test.ts +23 -0
  331. package/tests/fixtures-tracke/tests/billing.test.ts +14 -0
  332. package/tests/fixtures-trackf/package.json +5 -0
  333. package/tests/fixtures-trackf/src/auth.ts +26 -0
  334. package/tests/fixtures-trackf/src/handlers.ts +35 -0
  335. package/tests/fixtures-tracki/billing/routes.ts +12 -0
  336. package/tests/fixtures-tracki/gateway/client.ts +13 -0
  337. package/tests/git-features.ts +267 -0
  338. package/tests/init.ts +141 -0
  339. package/tests/mcp-jit.ts +130 -0
  340. package/tests/mcp-smoke.ts +191 -0
  341. package/tests/mcp-trackcd.ts +169 -0
  342. package/tests/mcp-tracke.ts +229 -0
  343. package/tests/mcp-trackf.ts +330 -0
  344. package/tests/mcp-trackg.ts +219 -0
  345. package/tests/mcp-tracki.ts +174 -0
  346. package/tests/mcp-watcher.ts +126 -0
  347. package/tests/optspec.ts +194 -0
  348. package/tests/parallel-index.ts +333 -0
  349. package/tests/parallel-read.ts +125 -0
  350. package/tests/parallel-recovery.ts +241 -0
  351. package/tests/perf-callers.ts +145 -0
  352. package/tests/query-parity.ts +184 -0
  353. package/tests/query-perf.ts +55 -0
  354. package/tests/scale-parallel-parity.ts +225 -0
  355. package/tests/scale-test.ts +523 -0
  356. package/tests/smoke.ts +396 -0
  357. package/tests/trackcd.ts +325 -0
  358. package/tests/tracke-collisions.ts +255 -0
  359. package/tests/tracke.ts +314 -0
  360. package/tests/trackf-bugs.ts +406 -0
  361. package/tests/trackf.ts +390 -0
  362. package/tests/trackg.ts +1372 -0
  363. package/tests/tracki-boundaries.ts +202 -0
  364. package/tests/tracki-continuity.ts +253 -0
  365. package/tests/tracki-contract-diff.ts +249 -0
  366. package/tests/tracki-external-bundles.ts +341 -0
  367. package/tests/tracki-preflight.ts +251 -0
  368. package/tests/verify-roles.ts +51 -0
  369. package/tests/worker-parity.ts +286 -0
  370. package/tests/worker-pool.ts +262 -0
  371. package/tsconfig.json +20 -0
@@ -0,0 +1,3888 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Store = void 0;
7
+ exports.isRankableKind = isRankableKind;
8
+ exports.splitIdentifierTokens = splitIdentifierTokens;
9
+ exports.makeSymbolKey = makeSymbolKey;
10
+ exports.ftsQuery = ftsQuery;
11
+ const path_1 = __importDefault(require("path"));
12
+ const node_sqlite_1 = require("node:sqlite");
13
+ const schema_js_1 = require("./schema.js");
14
+ /**
15
+ * Which symbol kinds participate in PageRank, ranking, and the default
16
+ * symbol list. Functions/methods/constructors/classes are rankable because
17
+ * they are call targets — edges flow into them and meaningful behavior lives
18
+ * there. Structs, enums, type aliases, interfaces, and variables are not
19
+ * rankable: they are type/state declarations, not call targets.
20
+ *
21
+ * Excluding non-rankable kinds from PageRank is a correctness fix as much as
22
+ * an optimization. With them included, the graph has hundreds of thousands of
23
+ * isolated zero-edge nodes (every struct/enum row) that absorb the (1-d)/n
24
+ * mass on each iteration but never propagate it. That dilutes every real
25
+ * function's score and inflates compute time linearly with the noise count.
26
+ */
27
+ const RANKABLE_KINDS = new Set([
28
+ 'function', 'method', 'constructor', 'class',
29
+ ]);
30
+ const SERVICE_CALLS_BACKFILL_VERSION = '1';
31
+ function isRankableKind(kind) {
32
+ return RANKABLE_KINDS.has(kind);
33
+ }
34
+ function toNum(v) { return Number(v); }
35
+ /** Escape SQLite LIKE metacharacters (`%`, `_`, `\`) for use with ESCAPE '\'.
36
+ * Lets a literal filename like `bom_crlf.ts` match without `_` acting as a
37
+ * single-char wildcard. */
38
+ function escapeLike(s) {
39
+ return s.replace(/[\\%_]/g, m => '\\' + m);
40
+ }
41
+ function toStr(v) { return String(v ?? ''); }
42
+ function toNullStr(v) { return v == null ? null : String(v); }
43
+ function toNullNum(v) { return v == null ? null : Number(v); }
44
+ /**
45
+ * Convert a 64-bit unsigned bigint shape hash into a signed bigint suitable
46
+ * for storage in an SQLite INTEGER column. We treat the high bit as the sign,
47
+ * so `0x8000_0000_0000_0000` and above wrap into negative values; this round-
48
+ * trips losslessly with `toUnsignedI64` below.
49
+ */
50
+ function toSignedI64(u) {
51
+ const MAX_I64 = 0x7fffffffffffffffn;
52
+ return u > MAX_I64 ? u - 0x10000000000000000n : u;
53
+ }
54
+ function toUnsignedI64(v) {
55
+ if (v == null)
56
+ return 0n;
57
+ const b = typeof v === 'bigint' ? v : BigInt(Number(v));
58
+ return b < 0n ? b + 0x10000000000000000n : b;
59
+ }
60
+ /**
61
+ * Build the per-table predicate clauses for the default project-first lens.
62
+ * Used by `findSymbols` / `getDefinition` / `getTopSymbols` / `countSymbols`
63
+ * and the MCP tool wrappers around them. Each `include*` flag turns OFF the
64
+ * corresponding restriction.
65
+ *
66
+ * The function is forgiving about pre-v4 / pre-v5 DBs: when the role columns
67
+ * or the symbol_role column don't exist on disk, the corresponding clauses
68
+ * are simply dropped so a read-only open against an old index keeps working.
69
+ */
70
+ function buildRoleFilter(filePrefix, includeVendor, includeGenerated, hasRoleColumns, options) {
71
+ const clauses = [];
72
+ if (hasRoleColumns) {
73
+ if (!includeVendor)
74
+ clauses.push(`${filePrefix}is_vendor = 0`);
75
+ if (!includeGenerated)
76
+ clauses.push(`${filePrefix}is_generated = 0`);
77
+ if (options && options.includeTests === false)
78
+ clauses.push(`${filePrefix}role <> 'test'`);
79
+ }
80
+ if (options?.hasSymbolRoleColumn) {
81
+ const sp = options.symbolPrefix ?? 's.';
82
+ if (options.includeDeclarations === false)
83
+ clauses.push(`${sp}symbol_role <> 'declaration'`);
84
+ if (options.includeTypeRefs === false)
85
+ clauses.push(`${sp}symbol_role <> 'type_ref'`);
86
+ }
87
+ return clauses.length === 0 ? '' : 'AND ' + clauses.join(' AND ');
88
+ }
89
+ /**
90
+ * Resolve the agent-facing query defaults for the include-flags. The contract:
91
+ * - vendor / generated stay hidden by default (existing behavior).
92
+ * - tests stay hidden by default for ranking/search tools, on top of the
93
+ * existing file-role classification. seer_behavior overrides via
94
+ * includeTests=true since tests ARE its content.
95
+ * - declarations stay hidden by default so callers/top-by-rank focus on
96
+ * real definition sites.
97
+ * - type_refs stay hidden by default (and aren't even produced yet).
98
+ */
99
+ function resolveSearchFlags(opts) {
100
+ return {
101
+ includeVendor: opts.includeVendor ?? false,
102
+ includeGenerated: opts.includeGenerated ?? false,
103
+ includeTests: opts.includeTests ?? false,
104
+ includeDeclarations: opts.includeDeclarations ?? false,
105
+ includeTypeRefs: opts.includeTypeRefs ?? false,
106
+ };
107
+ }
108
+ /**
109
+ * Split an identifier into searchable tokens. Used at FTS-insert time so a
110
+ * query for "auth" finds `AuthService`, `auth_service`, `authService`, and
111
+ * `AuthServiceImpl` alike.
112
+ *
113
+ * - splits on _ and -
114
+ * - splits camelCase boundaries (`AuthService` → "Auth Service Auth_Service")
115
+ * - splits consecutive caps like XMLParser → "XML Parser"
116
+ * - always includes the original token so prefix matches still work
117
+ */
118
+ function splitIdentifierTokens(s) {
119
+ if (!s)
120
+ return '';
121
+ const seen = new Set();
122
+ const push = (t) => { if (t)
123
+ seen.add(t.toLowerCase()); };
124
+ push(s);
125
+ // Split on . _ - / : ::
126
+ for (const part of s.split(/[._\-/:]+/)) {
127
+ push(part);
128
+ // CamelCase / PascalCase split: split before an upper-case letter that's
129
+ // either preceded by a lower-case letter, or followed by a lower-case
130
+ // letter when preceded by another upper-case letter (XMLParser → XML, Parser).
131
+ const camel = part.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
132
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2');
133
+ for (const tok of camel.split(/\s+/))
134
+ push(tok);
135
+ }
136
+ return Array.from(seen).join(' ');
137
+ }
138
+ class Store {
139
+ db;
140
+ readonly;
141
+ cachedSchemaInfo;
142
+ hasRoleColumns;
143
+ hasComplexityColumns;
144
+ hasV4Tables;
145
+ /**
146
+ * True when the v5 `symbols.symbol_role` column exists. Read-only opens
147
+ * against a pre-v5 DB transparently skip declaration/type_ref filtering;
148
+ * writer opens always have it since runMigrations() adds the column.
149
+ */
150
+ hasSymbolRoleColumn;
151
+ /**
152
+ * True when the v6 module tables (modules / module_members / module_edges)
153
+ * exist. Read-only opens against a pre-v6 DB skip module queries gracefully
154
+ * (they return empty arrays); writer opens always have it.
155
+ */
156
+ hasModuleTables;
157
+ /**
158
+ * True when the v7 provenance/shape_hash columns + scip_imports table exist.
159
+ * Read-only opens against a pre-v7 DB return empty arrays for SCIP / dup
160
+ * queries and skip the provenance column on selects.
161
+ */
162
+ hasV7Columns;
163
+ /**
164
+ * True when v10 external_bundles / boundaries / symbol_history_continuity
165
+ * tables exist. Read-only opens against a pre-v10 DB return empty arrays.
166
+ */
167
+ hasV10Tables;
168
+ // Prepared statements — initialized in constructor (writer path only)
169
+ stmtUpsertFile;
170
+ stmtInsertSymbol;
171
+ stmtInsertEdge;
172
+ stmtInsertFileImport;
173
+ stmtInsertRoute;
174
+ stmtInsertConfigKey;
175
+ stmtInsertExternalDep;
176
+ stmtInsertServiceCall;
177
+ stmtInsertServiceLink;
178
+ stmtInsertSymbolsFts;
179
+ stmtInsertFilesFts;
180
+ stmtDeleteSymbolsFtsForFile;
181
+ stmtDeleteFilesFtsForFile;
182
+ constructor(dbPath, options = {}) {
183
+ this.readonly = Boolean(options.readonly);
184
+ const busyMs = options.busyTimeoutMs ?? 5000;
185
+ if (this.readonly) {
186
+ this.db = new node_sqlite_1.DatabaseSync(dbPath, { readOnly: true });
187
+ try {
188
+ this.db.exec(`PRAGMA busy_timeout = ${busyMs}; PRAGMA query_only = ON;`);
189
+ }
190
+ catch { /* best effort */ }
191
+ }
192
+ else {
193
+ this.db = new node_sqlite_1.DatabaseSync(dbPath);
194
+ this.db.exec(schema_js_1.SCHEMA_SQL);
195
+ try {
196
+ this.db.exec(`PRAGMA busy_timeout = ${busyMs};`);
197
+ }
198
+ catch { /* best effort */ }
199
+ this.runMigrations();
200
+ this.prepare();
201
+ }
202
+ this.cachedSchemaInfo = this.readSchemaInfo();
203
+ this.hasRoleColumns = this.checkHasRoleColumns();
204
+ this.hasComplexityColumns = this.hasColumn('symbols', 'cyclomatic');
205
+ this.hasV4Tables = this.checkHasV4Tables();
206
+ this.hasSymbolRoleColumn = this.hasColumn('symbols', 'symbol_role');
207
+ this.hasModuleTables = this.checkHasModuleTables();
208
+ this.hasV7Columns = this.hasColumn('symbols', 'provenance') && this.hasColumn('symbols', 'shape_hash');
209
+ this.hasV10Tables = this.checkHasV10Tables();
210
+ }
211
+ checkHasV10Tables() {
212
+ try {
213
+ const rows = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('external_bundles','boundaries','boundary_members','boundary_edges','symbol_history_continuity')").all();
214
+ return rows.length === 5;
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
220
+ checkHasModuleTables() {
221
+ try {
222
+ const rows = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('modules','module_members','module_edges')").all();
223
+ return rows.length === 3;
224
+ }
225
+ catch {
226
+ return false;
227
+ }
228
+ }
229
+ checkHasRoleColumns() {
230
+ try {
231
+ const cols = this.db.prepare('PRAGMA table_info(files)').all();
232
+ const names = new Set(cols.map(c => toStr(c.name)));
233
+ return names.has('role') && names.has('is_vendor') && names.has('is_generated');
234
+ }
235
+ catch {
236
+ return false;
237
+ }
238
+ }
239
+ checkHasV4Tables() {
240
+ try {
241
+ const rows = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('routes','external_dependencies','config_keys','file_churn','symbol_history','git_index_state')").all();
242
+ return rows.length === 6;
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ }
248
+ static openReadOnly(dbPath, busyTimeoutMs) {
249
+ return new Store(dbPath, { readonly: true, busyTimeoutMs });
250
+ }
251
+ isReadOnly() { return this.readonly; }
252
+ assertWritable() {
253
+ if (this.readonly) {
254
+ throw new Error('Store is read-only; open a writable Store to mutate the index');
255
+ }
256
+ }
257
+ schemaInfo() { return this.cachedSchemaInfo; }
258
+ /**
259
+ * v8 Track-G migration guard. When an existing v7 DB is opened by v8 code,
260
+ * service_calls/service_links tables are created empty. A normal cached
261
+ * re-index would skip every unchanged file, so service_calls would remain
262
+ * empty forever. Until an index run marks this backfill version complete,
263
+ * the indexer must force one full parse pass.
264
+ */
265
+ needsServiceCallBackfill() {
266
+ try {
267
+ const row = this.db.prepare("SELECT value FROM _schema_meta WHERE key = 'service_calls_backfilled'").get();
268
+ if (row && toStr(row.value) === SERVICE_CALLS_BACKFILL_VERSION)
269
+ return false;
270
+ const files = this.db.prepare('SELECT COUNT(*) AS c FROM files').get();
271
+ return toNum(files.c) > 0;
272
+ }
273
+ catch {
274
+ return false;
275
+ }
276
+ }
277
+ markServiceCallsBackfilled() {
278
+ this.assertWritable();
279
+ this.db.prepare("INSERT INTO _schema_meta (key, value) VALUES ('service_calls_backfilled', ?) " +
280
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(SERVICE_CALLS_BACKFILL_VERSION);
281
+ }
282
+ readSchemaInfo() {
283
+ let dbVersion = 0;
284
+ try {
285
+ const row = this.db.prepare("SELECT value FROM _schema_meta WHERE key = 'schema_version'").get();
286
+ if (row)
287
+ dbVersion = parseInt(toStr(row.value), 10) || 0;
288
+ }
289
+ catch { /* */ }
290
+ return {
291
+ dbVersion,
292
+ buildVersion: schema_js_1.CURRENT_SCHEMA_VERSION,
293
+ current: dbVersion === schema_js_1.CURRENT_SCHEMA_VERSION,
294
+ };
295
+ }
296
+ runMigrations() {
297
+ this.addColumnIfMissing('symbols', 'qualified_name', 'TEXT');
298
+ this.addColumnIfMissing('file_imports', 'resolved_file_id', 'INTEGER REFERENCES files(id) ON DELETE SET NULL');
299
+ this.addColumnIfMissing('files', 'role', "TEXT NOT NULL DEFAULT 'project'");
300
+ this.addColumnIfMissing('files', 'is_vendor', 'INTEGER NOT NULL DEFAULT 0');
301
+ this.addColumnIfMissing('files', 'is_generated', 'INTEGER NOT NULL DEFAULT 0');
302
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_files_role ON files(role)');
303
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_files_is_vendor ON files(is_vendor)');
304
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_files_is_generated ON files(is_generated)');
305
+ // v3: is_rankable
306
+ const isV3Migration = !this.hasColumn('symbols', 'is_rankable');
307
+ this.addColumnIfMissing('symbols', 'is_rankable', 'INTEGER NOT NULL DEFAULT 1');
308
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_is_rankable ON symbols(is_rankable)');
309
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_file_name ON symbols(file_id, name)');
310
+ if (isV3Migration) {
311
+ this.db.prepare(`UPDATE symbols SET is_rankable = 0 WHERE kind NOT IN ('function','method','constructor','class')`).run();
312
+ this.db.prepare('UPDATE symbols SET pagerank = 0 WHERE is_rankable = 0').run();
313
+ }
314
+ // v4: complexity columns, symbol_key, edges.kind index
315
+ const isV4Migration = !this.hasColumn('symbols', 'symbol_key');
316
+ this.addColumnIfMissing('symbols', 'loc', 'INTEGER');
317
+ this.addColumnIfMissing('symbols', 'cyclomatic', 'INTEGER');
318
+ this.addColumnIfMissing('symbols', 'cognitive', 'INTEGER');
319
+ this.addColumnIfMissing('symbols', 'max_nesting', 'INTEGER');
320
+ this.addColumnIfMissing('symbols', 'symbol_key', 'TEXT');
321
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_symbol_key ON symbols(symbol_key)');
322
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind)');
323
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_from_to_kind ON edges(from_id, to_id, kind)');
324
+ // v4.1: separate history HEAD marker so churn doesn't poison the
325
+ // skip-if-unchanged check used by buildSymbolHistory. Cheap ALTER ADD;
326
+ // existing DBs get NULL which forces history to run on next invocation.
327
+ this.addColumnIfMissing('git_index_state', 'last_history_head_sha', 'TEXT');
328
+ this.addColumnIfMissing('git_index_state', 'last_history_at', 'INTEGER');
329
+ // v5: symbol_role on symbols. The NOT NULL DEFAULT 'definition' on the
330
+ // ALTER means every pre-v5 row gets a sane default without an explicit
331
+ // UPDATE backfill. The role only changes its meaning when the indexer
332
+ // re-runs against the file (e.g. for C/C++ fixtures where field_declaration
333
+ // is now emitted as 'declaration').
334
+ this.addColumnIfMissing('symbols', 'symbol_role', "TEXT NOT NULL DEFAULT 'definition'");
335
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_symbol_role ON symbols(symbol_role)');
336
+ // v7: provenance + shape_hash on symbols/edges, plus scip_imports table.
337
+ // ALTER ADD COLUMN paths are cheap and idempotent; the index creation is
338
+ // guarded by hasColumn so a partial migration on an older DB doesn't fail.
339
+ this.addColumnIfMissing('symbols', 'provenance', "TEXT NOT NULL DEFAULT 'tree-sitter'");
340
+ this.addColumnIfMissing('symbols', 'shape_hash', 'INTEGER');
341
+ this.addColumnIfMissing('edges', 'provenance', "TEXT NOT NULL DEFAULT 'tree-sitter'");
342
+ // v7.1 — scip_import_id links a SCIP-provenance row back to the
343
+ // scip_imports table entry that produced it, so re-importing or clearing
344
+ // ONE SCIP layer doesn't nuke rows contributed by sibling layers (the
345
+ // original v7 wipe was global, which collapsed multi-layer setups).
346
+ this.addColumnIfMissing('symbols', 'scip_import_id', 'INTEGER');
347
+ this.addColumnIfMissing('edges', 'scip_import_id', 'INTEGER');
348
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_provenance ON symbols(provenance)');
349
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_shape_hash ON symbols(shape_hash) WHERE shape_hash IS NOT NULL');
350
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance)');
351
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_symbols_scip_import ON symbols(scip_import_id)');
352
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_scip_import ON edges(scip_import_id)');
353
+ this.db.exec(`
354
+ CREATE TABLE IF NOT EXISTS scip_imports (
355
+ id INTEGER PRIMARY KEY,
356
+ path TEXT NOT NULL,
357
+ sha256 TEXT NOT NULL,
358
+ tool TEXT,
359
+ project_root TEXT,
360
+ imported_at INTEGER NOT NULL,
361
+ symbol_count INTEGER NOT NULL DEFAULT 0,
362
+ ref_count INTEGER NOT NULL DEFAULT 0,
363
+ UNIQUE(path, sha256)
364
+ );
365
+ CREATE INDEX IF NOT EXISTS idx_scip_imports_path ON scip_imports(path);
366
+ `);
367
+ // v6: modules + module_members + module_edges. CREATE TABLE IF NOT EXISTS
368
+ // is the migration — pre-v6 DBs get the tables on first writer open.
369
+ // No backfill needed: the clustering pass repopulates them on the next
370
+ // index run (it always runs when the graph changed; otherwise the cached
371
+ // membership stays valid because the graph it was built from stays valid).
372
+ this.db.exec(`
373
+ CREATE TABLE IF NOT EXISTS modules (
374
+ id INTEGER PRIMARY KEY,
375
+ label TEXT NOT NULL,
376
+ size_files INTEGER NOT NULL DEFAULT 0,
377
+ size_symbols INTEGER NOT NULL DEFAULT 0,
378
+ primary_language TEXT,
379
+ cohesion REAL NOT NULL DEFAULT 0,
380
+ centrality REAL NOT NULL DEFAULT 0,
381
+ computed_at INTEGER NOT NULL DEFAULT 0,
382
+ algorithm TEXT NOT NULL DEFAULT 'louvain'
383
+ );
384
+ CREATE INDEX IF NOT EXISTS idx_modules_label ON modules(label);
385
+ CREATE INDEX IF NOT EXISTS idx_modules_centrality ON modules(centrality DESC);
386
+ CREATE INDEX IF NOT EXISTS idx_modules_size ON modules(size_files DESC);
387
+ CREATE TABLE IF NOT EXISTS module_members (
388
+ file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
389
+ module_id INTEGER NOT NULL REFERENCES modules(id) ON DELETE CASCADE
390
+ );
391
+ CREATE INDEX IF NOT EXISTS idx_module_members_module ON module_members(module_id);
392
+ CREATE TABLE IF NOT EXISTS module_edges (
393
+ id INTEGER PRIMARY KEY,
394
+ from_module_id INTEGER NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
395
+ to_module_id INTEGER NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
396
+ kind TEXT NOT NULL DEFAULT 'call',
397
+ weight INTEGER NOT NULL DEFAULT 1,
398
+ UNIQUE(from_module_id, to_module_id, kind)
399
+ );
400
+ CREATE INDEX IF NOT EXISTS idx_module_edges_from ON module_edges(from_module_id);
401
+ CREATE INDEX IF NOT EXISTS idx_module_edges_to ON module_edges(to_module_id);
402
+ `);
403
+ // v8: Track-G service_calls + service_links. CREATE TABLE IF NOT EXISTS
404
+ // is the migration. Existing cached DBs need one forced parse pass to
405
+ // populate service_calls; needsServiceCallBackfill() + the indexer marker
406
+ // handle that so unchanged hashes do not leave the tables empty forever.
407
+ this.db.exec(`
408
+ CREATE TABLE IF NOT EXISTS service_calls (
409
+ id INTEGER PRIMARY KEY,
410
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
411
+ symbol_id INTEGER REFERENCES symbols(id) ON DELETE SET NULL,
412
+ protocol TEXT NOT NULL,
413
+ method TEXT,
414
+ raw_target TEXT NOT NULL,
415
+ normalized_path TEXT,
416
+ host_hint TEXT,
417
+ env_key TEXT,
418
+ framework TEXT NOT NULL,
419
+ line INTEGER NOT NULL DEFAULT 0,
420
+ confidence REAL NOT NULL DEFAULT 0.5
421
+ );
422
+ CREATE INDEX IF NOT EXISTS idx_service_calls_symbol_id ON service_calls(symbol_id);
423
+ CREATE INDEX IF NOT EXISTS idx_service_calls_path ON service_calls(normalized_path);
424
+ CREATE INDEX IF NOT EXISTS idx_service_calls_protocol ON service_calls(protocol);
425
+ CREATE INDEX IF NOT EXISTS idx_service_calls_file_id ON service_calls(file_id);
426
+
427
+ CREATE TABLE IF NOT EXISTS service_links (
428
+ id INTEGER PRIMARY KEY,
429
+ call_id INTEGER NOT NULL REFERENCES service_calls(id) ON DELETE CASCADE,
430
+ route_id INTEGER REFERENCES routes(id) ON DELETE CASCADE,
431
+ caller_symbol_id INTEGER REFERENCES symbols(id) ON DELETE SET NULL,
432
+ handler_symbol_id INTEGER REFERENCES symbols(id) ON DELETE SET NULL,
433
+ protocol TEXT NOT NULL,
434
+ match_kind TEXT NOT NULL,
435
+ confidence REAL NOT NULL,
436
+ evidence_json TEXT NOT NULL DEFAULT '{}'
437
+ );
438
+ CREATE INDEX IF NOT EXISTS idx_service_links_call_id ON service_links(call_id);
439
+ CREATE INDEX IF NOT EXISTS idx_service_links_handler ON service_links(handler_symbol_id);
440
+ CREATE INDEX IF NOT EXISTS idx_service_links_caller ON service_links(caller_symbol_id);
441
+ CREATE INDEX IF NOT EXISTS idx_service_links_protocol ON service_links(protocol);
442
+ CREATE INDEX IF NOT EXISTS idx_service_links_match_kind ON service_links(match_kind);
443
+ `);
444
+ // v9: Track-H protocol expansion. Adds generalized columns to service_calls
445
+ // and routes so non-HTTP protocols (tRPC / GraphQL / gRPC / Kafka / etc.)
446
+ // can be stored alongside HTTP without one column per protocol. All
447
+ // additions are nullable (or default 'http' for routes.protocol) so v8 DBs
448
+ // upgrade in-place with no data rewrite. Existing HTTP rows keep working
449
+ // unchanged because the resolver still matches on normalized_path + method
450
+ // when the new fields are NULL.
451
+ this.addColumnIfMissing('service_calls', 'operation', 'TEXT');
452
+ this.addColumnIfMissing('service_calls', 'topic', 'TEXT');
453
+ this.addColumnIfMissing('service_calls', 'queue', 'TEXT');
454
+ this.addColumnIfMissing('service_calls', 'exchange', 'TEXT');
455
+ this.addColumnIfMissing('service_calls', 'service', 'TEXT');
456
+ this.addColumnIfMissing('service_calls', 'broker', 'TEXT');
457
+ this.addColumnIfMissing('service_calls', 'metadata_json', 'TEXT');
458
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_calls_operation ON service_calls(operation) WHERE operation IS NOT NULL');
459
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_calls_topic ON service_calls(topic) WHERE topic IS NOT NULL');
460
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_calls_queue ON service_calls(queue) WHERE queue IS NOT NULL');
461
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_calls_service ON service_calls(service) WHERE service IS NOT NULL');
462
+ this.addColumnIfMissing('routes', 'protocol', "TEXT NOT NULL DEFAULT 'http'");
463
+ this.addColumnIfMissing('routes', 'operation', 'TEXT');
464
+ this.addColumnIfMissing('routes', 'topic', 'TEXT');
465
+ this.addColumnIfMissing('routes', 'queue', 'TEXT');
466
+ this.addColumnIfMissing('routes', 'exchange', 'TEXT');
467
+ this.addColumnIfMissing('routes', 'service', 'TEXT');
468
+ this.addColumnIfMissing('routes', 'broker', 'TEXT');
469
+ this.addColumnIfMissing('routes', 'metadata_json', 'TEXT');
470
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_protocol ON routes(protocol)');
471
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_operation ON routes(operation) WHERE operation IS NOT NULL');
472
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_topic ON routes(topic) WHERE topic IS NOT NULL');
473
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_queue ON routes(queue) WHERE queue IS NOT NULL');
474
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_service ON routes(service) WHERE service IS NOT NULL');
475
+ // v10 — external bundle layers + monorepo boundaries + history continuity.
476
+ // CREATE IF NOT EXISTS + ALTER ADD COLUMN keep older DBs upgradable
477
+ // without data rewrites. The default values are chosen so HTTP/local
478
+ // behavior is unchanged on rows that don't set the new fields.
479
+ this.db.exec(`
480
+ CREATE TABLE IF NOT EXISTS external_bundles (
481
+ id INTEGER PRIMARY KEY,
482
+ source_kind TEXT NOT NULL DEFAULT 'external-bundle',
483
+ bundle_path TEXT NOT NULL,
484
+ external_project TEXT,
485
+ external_version TEXT,
486
+ external_hash TEXT,
487
+ schema_version INTEGER NOT NULL DEFAULT 0,
488
+ imported_at INTEGER NOT NULL,
489
+ routes_imported INTEGER NOT NULL DEFAULT 0,
490
+ service_calls_imported INTEGER NOT NULL DEFAULT 0,
491
+ service_links_imported INTEGER NOT NULL DEFAULT 0,
492
+ UNIQUE(bundle_path)
493
+ );
494
+ CREATE INDEX IF NOT EXISTS idx_external_bundles_project ON external_bundles(external_project);
495
+ CREATE TABLE IF NOT EXISTS boundaries (
496
+ id INTEGER PRIMARY KEY,
497
+ label TEXT NOT NULL,
498
+ kind TEXT NOT NULL DEFAULT 'package',
499
+ root_rel_path TEXT NOT NULL,
500
+ manifest_path TEXT,
501
+ ecosystem TEXT,
502
+ size_files INTEGER NOT NULL DEFAULT 0,
503
+ computed_at INTEGER NOT NULL DEFAULT 0,
504
+ UNIQUE(root_rel_path)
505
+ );
506
+ CREATE INDEX IF NOT EXISTS idx_boundaries_label ON boundaries(label);
507
+ CREATE INDEX IF NOT EXISTS idx_boundaries_kind ON boundaries(kind);
508
+ CREATE TABLE IF NOT EXISTS boundary_members (
509
+ file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
510
+ boundary_id INTEGER NOT NULL REFERENCES boundaries(id) ON DELETE CASCADE
511
+ );
512
+ CREATE INDEX IF NOT EXISTS idx_boundary_members_boundary ON boundary_members(boundary_id);
513
+ CREATE TABLE IF NOT EXISTS boundary_edges (
514
+ id INTEGER PRIMARY KEY,
515
+ from_boundary_id INTEGER NOT NULL REFERENCES boundaries(id) ON DELETE CASCADE,
516
+ to_boundary_id INTEGER NOT NULL REFERENCES boundaries(id) ON DELETE CASCADE,
517
+ kind TEXT NOT NULL DEFAULT 'call',
518
+ weight INTEGER NOT NULL DEFAULT 1,
519
+ UNIQUE(from_boundary_id, to_boundary_id, kind)
520
+ );
521
+ CREATE INDEX IF NOT EXISTS idx_boundary_edges_from ON boundary_edges(from_boundary_id);
522
+ CREATE INDEX IF NOT EXISTS idx_boundary_edges_to ON boundary_edges(to_boundary_id);
523
+ CREATE TABLE IF NOT EXISTS symbol_history_continuity (
524
+ id INTEGER PRIMARY KEY,
525
+ symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
526
+ symbol_key TEXT NOT NULL,
527
+ previous_symbol_key TEXT,
528
+ previous_name TEXT,
529
+ previous_file TEXT,
530
+ bridging_sha TEXT,
531
+ confidence REAL NOT NULL DEFAULT 0.0,
532
+ match_reasons TEXT NOT NULL DEFAULT '[]',
533
+ recorded_at INTEGER NOT NULL,
534
+ UNIQUE(symbol_id, previous_symbol_key)
535
+ );
536
+ CREATE INDEX IF NOT EXISTS idx_symbol_history_continuity_symbol ON symbol_history_continuity(symbol_id);
537
+ CREATE INDEX IF NOT EXISTS idx_symbol_history_continuity_prev ON symbol_history_continuity(previous_symbol_key);
538
+ `);
539
+ // v10 — external_bundle_id columns on rows that can come from an external
540
+ // layer. NULL = local row (default).
541
+ this.addColumnIfMissing('routes', 'external_bundle_id', 'INTEGER');
542
+ this.addColumnIfMissing('service_calls', 'external_bundle_id', 'INTEGER');
543
+ this.addColumnIfMissing('service_links', 'external_bundle_id', 'INTEGER');
544
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_routes_external_bundle ON routes(external_bundle_id) WHERE external_bundle_id IS NOT NULL');
545
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_calls_external_bundle ON service_calls(external_bundle_id) WHERE external_bundle_id IS NOT NULL');
546
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_service_links_external_bundle ON service_links(external_bundle_id) WHERE external_bundle_id IS NOT NULL');
547
+ // v4 backfill — required because upsertFileWithCache() short-circuits on
548
+ // unchanged content hash, so a v3 DB upgraded to v4 would never get
549
+ // symbol_key populated (nor FTS rebuilt) for any file whose source hadn't
550
+ // changed. That left seer_history with zero candidates and FTS search
551
+ // returning empty for the entire pre-upgrade corpus until a manual
552
+ // --reset. Both backfills are cheap and idempotent.
553
+ if (isV4Migration) {
554
+ this.backfillSymbolKeysFromExistingRows();
555
+ }
556
+ // FTS rebuild: detect "v4 columns exist but FTS tables are empty while
557
+ // symbols/files have rows". Triggers on the v3→v4 upgrade AND on the rare
558
+ // case where a v4 DB lost its FTS rows (e.g. a manual schema patch). The
559
+ // check is constant-time (COUNT on empty FTS is instant).
560
+ this.rebuildFtsIfStale();
561
+ this.db.prepare("INSERT INTO _schema_meta (key, value) VALUES ('schema_version', ?) " +
562
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(schema_js_1.CURRENT_SCHEMA_VERSION));
563
+ }
564
+ /**
565
+ * Populate symbols.symbol_key for every existing row. Mirrors
566
+ * makeSymbolKey() — `kind:qualified_name` (or `kind:name` if qualified is
567
+ * NULL). symbol_history is keyed on these so without the backfill,
568
+ * listSymbolsForHistoryIndex() returns zero candidates after a v3→v4
569
+ * upgrade.
570
+ */
571
+ backfillSymbolKeysFromExistingRows() {
572
+ try {
573
+ this.db.exec(`
574
+ UPDATE symbols
575
+ SET symbol_key = kind || ':' || COALESCE(qualified_name, name)
576
+ WHERE symbol_key IS NULL
577
+ `);
578
+ }
579
+ catch { /* table may not exist on a brand-new DB; non-fatal */ }
580
+ }
581
+ /**
582
+ * Rebuild symbols_fts / files_fts from the current symbols / files rows if
583
+ * either FTS table is empty while its source table has rows. This is the
584
+ * only safe trigger condition — Seer never deliberately leaves FTS empty
585
+ * while symbols are populated, so emptiness is a reliable "stale FTS"
586
+ * signal (post-migration or post-manual-patch).
587
+ */
588
+ rebuildFtsIfStale() {
589
+ try {
590
+ const sym = this.db.prepare('SELECT COUNT(*) AS c FROM symbols').get();
591
+ const symFts = this.db.prepare('SELECT COUNT(*) AS c FROM symbols_fts').get();
592
+ if (toNum(sym.c) > 0 && toNum(symFts.c) === 0) {
593
+ const ins = this.db.prepare('INSERT INTO symbols_fts(rowid, name, qualified_name, signature, split) VALUES (?, ?, ?, ?, ?)');
594
+ const rows = this.db.prepare('SELECT id, name, qualified_name, signature FROM symbols').all();
595
+ this.db.exec('BEGIN');
596
+ try {
597
+ for (const r of rows) {
598
+ const name = toStr(r.name);
599
+ const qual = toStr(r.qualified_name ?? r.name);
600
+ ins.run(toNum(r.id), name, qual, toStr(r.signature ?? ''), splitIdentifierTokens(`${name} ${qual}`));
601
+ }
602
+ this.db.exec('COMMIT');
603
+ }
604
+ catch (err) {
605
+ this.db.exec('ROLLBACK');
606
+ throw err;
607
+ }
608
+ }
609
+ }
610
+ catch { /* FTS5 unavailable; non-fatal */ }
611
+ try {
612
+ const file = this.db.prepare('SELECT COUNT(*) AS c FROM files').get();
613
+ const fileFts = this.db.prepare('SELECT COUNT(*) AS c FROM files_fts').get();
614
+ if (toNum(file.c) > 0 && toNum(fileFts.c) === 0) {
615
+ const ins = this.db.prepare('INSERT INTO files_fts(rowid, rel_path) VALUES (?, ?)');
616
+ const rows = this.db.prepare('SELECT id, rel_path FROM files').all();
617
+ this.db.exec('BEGIN');
618
+ try {
619
+ for (const r of rows) {
620
+ ins.run(toNum(r.id), splitIdentifierTokens(toStr(r.rel_path)));
621
+ }
622
+ this.db.exec('COMMIT');
623
+ }
624
+ catch (err) {
625
+ this.db.exec('ROLLBACK');
626
+ throw err;
627
+ }
628
+ }
629
+ }
630
+ catch { /* FTS5 unavailable; non-fatal */ }
631
+ }
632
+ hasColumn(table, column) {
633
+ try {
634
+ const cols = this.db.prepare(`PRAGMA table_info(${table})`).all();
635
+ return cols.some(c => toStr(c.name) === column);
636
+ }
637
+ catch {
638
+ return false;
639
+ }
640
+ }
641
+ addColumnIfMissing(table, column, def) {
642
+ const cols = this.db.prepare(`PRAGMA table_info(${table})`).all();
643
+ if (cols.some(c => toStr(c.name) === column))
644
+ return;
645
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${def}`);
646
+ }
647
+ prepare() {
648
+ this.stmtUpsertFile = this.db.prepare(`
649
+ INSERT INTO files (path, rel_path, language, hash, lines, indexed_at, role, is_vendor, is_generated)
650
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
651
+ ON CONFLICT(path) DO UPDATE SET
652
+ rel_path = excluded.rel_path,
653
+ language = excluded.language,
654
+ hash = excluded.hash,
655
+ lines = excluded.lines,
656
+ indexed_at = excluded.indexed_at,
657
+ role = excluded.role,
658
+ is_vendor = excluded.is_vendor,
659
+ is_generated = excluded.is_generated
660
+ `);
661
+ this.stmtInsertSymbol = this.db.prepare(`
662
+ INSERT INTO symbols
663
+ (name, qualified_name, kind, file_id, line_start, line_end, col_start, col_end,
664
+ signature, is_rankable, loc, cyclomatic, cognitive, max_nesting, symbol_key, symbol_role)
665
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
666
+ `);
667
+ this.stmtInsertEdge = this.db.prepare(`
668
+ INSERT INTO edges (from_id, to_name, kind, line) VALUES (?, ?, ?, ?)
669
+ `);
670
+ this.stmtInsertFileImport = this.db.prepare(`
671
+ INSERT OR IGNORE INTO file_imports (from_file_id, import_name) VALUES (?, ?)
672
+ `);
673
+ this.stmtInsertRoute = this.db.prepare(`
674
+ INSERT INTO routes
675
+ (file_id, method, path, framework, handler_name, line,
676
+ protocol, operation, topic, queue, exchange, service, broker, metadata_json)
677
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
678
+ `);
679
+ this.stmtInsertConfigKey = this.db.prepare(`
680
+ INSERT INTO config_keys (key, source, file_id, symbol_id, line)
681
+ VALUES (?, ?, ?, ?, ?)
682
+ `);
683
+ this.stmtInsertExternalDep = this.db.prepare(`
684
+ INSERT OR REPLACE INTO external_dependencies
685
+ (ecosystem, name, version_range, manifest_path, is_dev)
686
+ VALUES (?, ?, ?, ?, ?)
687
+ `);
688
+ this.stmtInsertServiceCall = this.db.prepare(`
689
+ INSERT INTO service_calls
690
+ (file_id, symbol_id, protocol, method, raw_target, normalized_path,
691
+ host_hint, env_key, framework, line, confidence,
692
+ operation, topic, queue, exchange, service, broker, metadata_json)
693
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
694
+ `);
695
+ this.stmtInsertServiceLink = this.db.prepare(`
696
+ INSERT INTO service_links
697
+ (call_id, route_id, caller_symbol_id, handler_symbol_id,
698
+ protocol, match_kind, confidence, evidence_json)
699
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
700
+ `);
701
+ this.stmtInsertSymbolsFts = this.db.prepare('INSERT INTO symbols_fts(rowid, name, qualified_name, signature, split) VALUES (?, ?, ?, ?, ?)');
702
+ this.stmtInsertFilesFts = this.db.prepare('INSERT INTO files_fts(rowid, rel_path) VALUES (?, ?)');
703
+ this.stmtDeleteSymbolsFtsForFile = this.db.prepare('DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file_id = ?)');
704
+ this.stmtDeleteFilesFtsForFile = this.db.prepare('DELETE FROM files_fts WHERE rowid = ?');
705
+ }
706
+ // ── Write operations ────────────────────────────────────────────────────────
707
+ pruneFilesNotIn(keepIds) {
708
+ this.assertWritable();
709
+ // v10 — external bundle phantom files use path 'external' as language so
710
+ // they're never pruned by accident on a cached re-index. The pruner adds
711
+ // them to keepIds before the delete pass so importing an external bundle,
712
+ // then running a regular `seer index`, leaves the external layer intact.
713
+ const externalIds = this.listExternalPhantomFileIds();
714
+ if (keepIds.size === 0 && externalIds.length === 0) {
715
+ const res = this.db.prepare('DELETE FROM files').run();
716
+ // FTS is contentless — wipe in parallel.
717
+ try {
718
+ this.db.exec('DELETE FROM symbols_fts');
719
+ this.db.exec('DELETE FROM files_fts');
720
+ }
721
+ catch { /* */ }
722
+ return toNum(res.changes);
723
+ }
724
+ this.db.exec('BEGIN');
725
+ try {
726
+ this.db.exec('CREATE TEMP TABLE IF NOT EXISTS _keep (id INTEGER PRIMARY KEY)');
727
+ this.db.exec('DELETE FROM _keep');
728
+ const insert = this.db.prepare('INSERT INTO _keep (id) VALUES (?)');
729
+ for (const id of keepIds)
730
+ insert.run(id);
731
+ for (const id of externalIds) {
732
+ try {
733
+ insert.run(id);
734
+ }
735
+ catch { /* duplicate keep id; ignore */ }
736
+ }
737
+ // Wipe FTS rows for files we're about to delete (pre-delete, before
738
+ // their ids become unrecoverable).
739
+ try {
740
+ this.db.exec(`
741
+ DELETE FROM symbols_fts WHERE rowid IN (
742
+ SELECT s.id FROM symbols s
743
+ JOIN files f ON f.id = s.file_id
744
+ WHERE f.id NOT IN (SELECT id FROM _keep)
745
+ )
746
+ `);
747
+ this.db.exec(`
748
+ DELETE FROM files_fts WHERE rowid IN (
749
+ SELECT id FROM files WHERE id NOT IN (SELECT id FROM _keep)
750
+ )
751
+ `);
752
+ }
753
+ catch { /* */ }
754
+ const res = this.db.prepare('DELETE FROM files WHERE id NOT IN (SELECT id FROM _keep)').run();
755
+ this.db.exec('COMMIT');
756
+ return toNum(res.changes);
757
+ }
758
+ catch (err) {
759
+ this.db.exec('ROLLBACK');
760
+ throw err;
761
+ }
762
+ }
763
+ upsertFile(path, relPath, language, hash, lines, classification = { role: 'project', isVendor: 0, isGenerated: 0 }) {
764
+ this.assertWritable();
765
+ const existing = this.db.prepare('SELECT id FROM files WHERE path = ?').get(path);
766
+ if (existing) {
767
+ const fileId = toNum(existing.id);
768
+ // Wipe FTS rows + dependent table rows for this file
769
+ try {
770
+ this.stmtDeleteSymbolsFtsForFile.run(fileId);
771
+ }
772
+ catch { /* */ }
773
+ this.db.prepare('DELETE FROM symbols WHERE file_id = ?').run(fileId);
774
+ this.db.prepare('DELETE FROM file_imports WHERE from_file_id = ?').run(fileId);
775
+ this.db.prepare('DELETE FROM routes WHERE file_id = ?').run(fileId);
776
+ this.db.prepare('DELETE FROM config_keys WHERE file_id = ?').run(fileId);
777
+ this.db.prepare('DELETE FROM service_calls WHERE file_id = ?').run(fileId);
778
+ try {
779
+ this.stmtDeleteFilesFtsForFile.run(fileId);
780
+ }
781
+ catch { /* */ }
782
+ }
783
+ const result = this.stmtUpsertFile.run(path, relPath, language, hash, lines, Date.now(), classification.role, classification.isVendor, classification.isGenerated);
784
+ const fileId = existing ? toNum(existing.id) : toNum(result.lastInsertRowid);
785
+ try {
786
+ this.stmtInsertFilesFts.run(fileId, splitIdentifierTokens(relPath));
787
+ }
788
+ catch { /* */ }
789
+ return fileId;
790
+ }
791
+ upsertFileWithCache(path, relPath, language, hash, lines, classification = { role: 'project', isVendor: 0, isGenerated: 0 }) {
792
+ this.assertWritable();
793
+ const existing = this.db
794
+ .prepare('SELECT id, hash, role, is_vendor, is_generated FROM files WHERE path = ?')
795
+ .get(path);
796
+ if (existing && toStr(existing.hash) === hash) {
797
+ const fileId = toNum(existing.id);
798
+ const existingRole = toStr(existing.role);
799
+ const existingVendor = toNum(existing.is_vendor);
800
+ const existingGen = toNum(existing.is_generated);
801
+ if (existingRole !== classification.role ||
802
+ existingVendor !== classification.isVendor ||
803
+ existingGen !== classification.isGenerated) {
804
+ this.db.prepare('UPDATE files SET indexed_at = ?, role = ?, is_vendor = ?, is_generated = ? WHERE id = ?').run(Date.now(), classification.role, classification.isVendor, classification.isGenerated, fileId);
805
+ }
806
+ else {
807
+ this.db.prepare('UPDATE files SET indexed_at = ? WHERE id = ?')
808
+ .run(Date.now(), fileId);
809
+ }
810
+ return { fileId, unchanged: true };
811
+ }
812
+ if (existing) {
813
+ const fileId = toNum(existing.id);
814
+ try {
815
+ this.stmtDeleteSymbolsFtsForFile.run(fileId);
816
+ }
817
+ catch { /* */ }
818
+ this.db.prepare('DELETE FROM symbols WHERE file_id = ?').run(fileId);
819
+ this.db.prepare('DELETE FROM file_imports WHERE from_file_id = ?').run(fileId);
820
+ this.db.prepare('DELETE FROM routes WHERE file_id = ?').run(fileId);
821
+ this.db.prepare('DELETE FROM config_keys WHERE file_id = ?').run(fileId);
822
+ this.db.prepare('DELETE FROM service_calls WHERE file_id = ?').run(fileId);
823
+ try {
824
+ this.stmtDeleteFilesFtsForFile.run(fileId);
825
+ }
826
+ catch { /* */ }
827
+ }
828
+ const result = this.stmtUpsertFile.run(path, relPath, language, hash, lines, Date.now(), classification.role, classification.isVendor, classification.isGenerated);
829
+ const fileId = existing ? toNum(existing.id) : toNum(result.lastInsertRowid);
830
+ try {
831
+ this.stmtInsertFilesFts.run(fileId, splitIdentifierTokens(relPath));
832
+ }
833
+ catch { /* */ }
834
+ return { fileId, unchanged: false };
835
+ }
836
+ insertSymbol(fileId, def) {
837
+ this.assertWritable();
838
+ const sig = def.signature ? def.signature.slice(0, 240) : null;
839
+ const qualified = def.qualifiedName ?? def.name;
840
+ const symbolRole = def.symbolRole ?? 'definition';
841
+ // Declarations are not call targets in the same canonical sense as
842
+ // definitions, so they're excluded from PageRank just like type rows.
843
+ // The kind-based rankability still applies — a class declaration would
844
+ // already be non-rankable from the kind check; this is the belt-and-
845
+ // suspenders guard for the rarer "method declaration" case.
846
+ const rankable = (symbolRole === 'definition' && isRankableKind(def.kind)) ? 1 : 0;
847
+ const symbolKey = makeSymbolKey(def.kind, qualified);
848
+ const result = this.stmtInsertSymbol.run(def.name, qualified, def.kind, fileId, def.lineStart, def.lineEnd, def.colStart, def.colEnd, sig, rankable, def.loc ?? null, def.cyclomatic ?? null, def.cognitive ?? null, def.maxNesting ?? null, symbolKey, symbolRole);
849
+ const symbolId = toNum(result.lastInsertRowid);
850
+ try {
851
+ this.stmtInsertSymbolsFts.run(symbolId, def.name, qualified, sig ?? '', splitIdentifierTokens(`${def.name} ${qualified}`));
852
+ }
853
+ catch { /* FTS5 unavailable; non-fatal */ }
854
+ return symbolId;
855
+ }
856
+ insertEdge(fromSymbolId, toName, kind, line) {
857
+ this.assertWritable();
858
+ this.stmtInsertEdge.run(fromSymbolId, toName, kind, line);
859
+ }
860
+ insertFileImport(fromFileId, importName) {
861
+ this.assertWritable();
862
+ this.stmtInsertFileImport.run(fromFileId, importName);
863
+ }
864
+ insertRoute(fileId, method, routePath, framework, handlerName, line, options = {}) {
865
+ this.assertWritable();
866
+ this.stmtInsertRoute.run(fileId, method, routePath, framework, handlerName, line, options.protocol ?? 'http', options.operation ?? null, options.topic ?? null, options.queue ?? null, options.exchange ?? null, options.service ?? null, options.broker ?? null, options.metadataJson ?? null);
867
+ }
868
+ insertConfigKey(key, source, fileId, symbolId, line) {
869
+ this.assertWritable();
870
+ this.stmtInsertConfigKey.run(key, source, fileId, symbolId, line);
871
+ }
872
+ /**
873
+ * v8 Track G — return a closure that inserts service_link rows. Used by
874
+ * the resolver in `resolveServiceLinks(store)` so it can stream inserts
875
+ * inside one prepared statement rather than re-resolving the statement
876
+ * per row.
877
+ */
878
+ makeServiceLinkInserter() {
879
+ this.assertWritable();
880
+ const stmt = this.stmtInsertServiceLink;
881
+ return (a) => {
882
+ stmt.run(a.callId, a.routeId, a.callerSymbolId, a.handlerSymbolId, a.protocol, a.matchKind, a.confidence, a.evidenceJson);
883
+ };
884
+ }
885
+ /**
886
+ * v8 Track G — insert a service-call row (outbound HTTP/etc. client call).
887
+ * The post-index resolver derives service_links from these and from routes.
888
+ * Returns the new row id so callers can attach evidence in the same batch.
889
+ */
890
+ insertServiceCall(args) {
891
+ this.assertWritable();
892
+ const r = this.stmtInsertServiceCall.run(args.fileId, args.symbolId, args.protocol, args.method, args.rawTarget.slice(0, 240), args.normalizedPath, args.hostHint, args.envKey, args.framework, args.line, args.confidence, args.operation ?? null, args.topic ?? null, args.queue ?? null, args.exchange ?? null, args.service ?? null, args.broker ?? null, args.metadataJson ?? null);
893
+ return toNum(r.lastInsertRowid);
894
+ }
895
+ insertExternalDep(ecosystem, name, versionRange, manifestPath, isDev) {
896
+ this.assertWritable();
897
+ this.stmtInsertExternalDep.run(ecosystem, name, versionRange, manifestPath, isDev);
898
+ }
899
+ clearExternalDeps() {
900
+ this.assertWritable();
901
+ this.db.exec('DELETE FROM external_dependencies');
902
+ }
903
+ // ── Import resolution ───────────────────────────────────────────────────────
904
+ resolveImports() {
905
+ const files = this.db.prepare('SELECT id, path, language FROM files').all();
906
+ if (files.length === 0)
907
+ return 0;
908
+ const fileByPath = new Map();
909
+ for (const f of files) {
910
+ fileByPath.set(normalizePath(toStr(f.path)), toNum(f.id));
911
+ }
912
+ const imports = this.db.prepare(`
913
+ SELECT fi.id, fi.from_file_id, fi.import_name, f.path AS from_path, f.language
914
+ FROM file_imports fi
915
+ JOIN files f ON f.id = fi.from_file_id
916
+ WHERE fi.resolved_file_id IS NULL
917
+ `).all();
918
+ const updateStmt = this.db.prepare('UPDATE file_imports SET resolved_file_id = ? WHERE id = ?');
919
+ let resolved = 0;
920
+ this.db.exec('BEGIN');
921
+ try {
922
+ for (const imp of imports) {
923
+ const fromPath = toStr(imp.from_path);
924
+ const language = toStr(imp.language);
925
+ const importName = toStr(imp.import_name);
926
+ const targetId = resolveImportToFileId(fromPath, language, importName, fileByPath);
927
+ if (targetId !== null) {
928
+ updateStmt.run(targetId, toNum(imp.id));
929
+ resolved++;
930
+ }
931
+ }
932
+ this.db.exec('COMMIT');
933
+ }
934
+ catch (err) {
935
+ this.db.exec('ROLLBACK');
936
+ throw err;
937
+ }
938
+ return resolved;
939
+ }
940
+ // ── Edge resolution (scope-aware) ───────────────────────────────────────────
941
+ resolveEdges() {
942
+ const countUnresolved = () => toNum(this.db.prepare('SELECT COUNT(*) AS c FROM edges WHERE to_id IS NULL').get().c);
943
+ const before0 = countUnresolved();
944
+ this.db.prepare(`
945
+ UPDATE edges
946
+ SET to_id = (
947
+ SELECT t.id
948
+ FROM symbols t, symbols s
949
+ WHERE s.id = edges.from_id
950
+ AND t.name = edges.to_name
951
+ AND t.file_id = s.file_id
952
+ LIMIT 1
953
+ )
954
+ WHERE to_id IS NULL
955
+ AND EXISTS (
956
+ SELECT 1
957
+ FROM symbols t, symbols s
958
+ WHERE s.id = edges.from_id
959
+ AND t.name = edges.to_name
960
+ AND t.file_id = s.file_id
961
+ );
962
+ `).run();
963
+ const after1 = countUnresolved();
964
+ const sameFile = before0 - after1;
965
+ this.db.prepare(`
966
+ UPDATE edges
967
+ SET to_id = (
968
+ SELECT t.id
969
+ FROM symbols t
970
+ JOIN file_imports fi ON fi.resolved_file_id = t.file_id
971
+ JOIN symbols s ON s.id = edges.from_id
972
+ WHERE fi.from_file_id = s.file_id
973
+ AND t.name = edges.to_name
974
+ LIMIT 1
975
+ )
976
+ WHERE to_id IS NULL
977
+ AND EXISTS (
978
+ SELECT 1
979
+ FROM symbols t
980
+ JOIN file_imports fi ON fi.resolved_file_id = t.file_id
981
+ JOIN symbols s ON s.id = edges.from_id
982
+ WHERE fi.from_file_id = s.file_id
983
+ AND t.name = edges.to_name
984
+ );
985
+ `).run();
986
+ const after2 = countUnresolved();
987
+ const imported = after1 - after2;
988
+ this.db.prepare(`
989
+ UPDATE edges
990
+ SET to_id = (
991
+ SELECT id FROM symbols WHERE name = edges.to_name LIMIT 1
992
+ )
993
+ WHERE to_id IS NULL
994
+ AND EXISTS (SELECT 1 FROM symbols WHERE name = edges.to_name);
995
+ `).run();
996
+ const after3 = countUnresolved();
997
+ const global = after2 - after3;
998
+ return {
999
+ sameFile,
1000
+ imported,
1001
+ global,
1002
+ total: sameFile + imported + global,
1003
+ };
1004
+ }
1005
+ /**
1006
+ * After symbol IDs are known, link routes.handler_id by name. Routes that
1007
+ * named a handler not defined in the same file stay with handler_id NULL.
1008
+ * Matching is by `handler_name = symbols.name` AND `file_id = routes.file_id`
1009
+ * — handlers nearly always live in the same file as the route registration.
1010
+ */
1011
+ resolveRouteHandlers() {
1012
+ const res = this.db.prepare(`
1013
+ UPDATE routes
1014
+ SET handler_id = (
1015
+ SELECT s.id FROM symbols s
1016
+ WHERE s.file_id = routes.file_id
1017
+ AND s.name = routes.handler_name
1018
+ LIMIT 1
1019
+ )
1020
+ WHERE handler_id IS NULL
1021
+ AND handler_name IS NOT NULL
1022
+ `).run();
1023
+ return toNum(res.changes);
1024
+ }
1025
+ /**
1026
+ * Backfill config_keys.symbol_id by line span. The extractor doesn't always
1027
+ * know the enclosing symbol id (extraction precedes symbol insertion), so we
1028
+ * resolve it via "the smallest function/method containing this line."
1029
+ */
1030
+ resolveConfigKeySymbols() {
1031
+ const res = this.db.prepare(`
1032
+ UPDATE config_keys
1033
+ SET symbol_id = (
1034
+ SELECT s.id FROM symbols s
1035
+ WHERE s.file_id = config_keys.file_id
1036
+ AND s.line_start <= config_keys.line
1037
+ AND s.line_end >= config_keys.line
1038
+ AND s.kind IN ('function','method','constructor')
1039
+ ORDER BY (s.line_end - s.line_start) ASC
1040
+ LIMIT 1
1041
+ )
1042
+ WHERE symbol_id IS NULL
1043
+ `).run();
1044
+ return toNum(res.changes);
1045
+ }
1046
+ // ── Test-edge synthesis ─────────────────────────────────────────────────────
1047
+ /**
1048
+ * Promote calls from a test-file symbol to a non-test target into 'tests'
1049
+ * edges (in addition to keeping the original 'call' edge). The original
1050
+ * call edge is left in place so caller/callee queries don't double-count;
1051
+ * test edges live in their own kind so `seer_behavior` can pull them
1052
+ * directly without scanning the full edge table.
1053
+ *
1054
+ * The synthesized edge copies the SOURCE 'call' edge's `to_id` verbatim —
1055
+ * the call-edge resolution pass already did the same-file / imported /
1056
+ * global fallback work to pick the correct target. Re-resolving by name
1057
+ * via `WHERE name = edges.to_name LIMIT 1` (the old behavior) was buggy
1058
+ * when two symbols shared the same short name (`Alpha.run` / `Beta.run`):
1059
+ * `LIMIT 1` would attribute every test edge to whichever id sorted first,
1060
+ * so `seer_behavior(Beta.run)` returned tests that actually exercised
1061
+ * `Alpha.run`. Preserving the source `to_id` matches what the original
1062
+ * resolver already chose.
1063
+ *
1064
+ * Returns the number of new test edges inserted.
1065
+ */
1066
+ synthesizeTestEdges() {
1067
+ // Find call edges from a test file to a non-test target whose 'tests'
1068
+ // counterpart doesn't yet exist.
1069
+ const rows = this.db.prepare(`
1070
+ SELECT e.from_id, e.to_id, e.to_name, e.line
1071
+ FROM edges e
1072
+ JOIN symbols s ON s.id = e.from_id
1073
+ JOIN files fs ON fs.id = s.file_id
1074
+ JOIN symbols t ON t.id = e.to_id
1075
+ JOIN files ft ON ft.id = t.file_id
1076
+ WHERE e.kind = 'call'
1077
+ AND fs.role = 'test'
1078
+ AND ft.role <> 'test'
1079
+ AND NOT EXISTS (
1080
+ SELECT 1 FROM edges e2
1081
+ WHERE e2.from_id = e.from_id
1082
+ AND e2.to_id = e.to_id
1083
+ AND e2.kind = 'tests'
1084
+ )
1085
+ `).all();
1086
+ if (rows.length === 0)
1087
+ return 0;
1088
+ // Insert with to_id set explicitly from the source edge — no LIMIT 1
1089
+ // name re-resolution that would collapse same-short-name symbols.
1090
+ const insert = this.db.prepare("INSERT INTO edges (from_id, to_name, to_id, kind, line) VALUES (?, ?, ?, 'tests', ?)");
1091
+ this.db.exec('BEGIN');
1092
+ try {
1093
+ for (const r of rows) {
1094
+ insert.run(toNum(r.from_id), toStr(r.to_name), toNum(r.to_id), toNum(r.line));
1095
+ }
1096
+ this.db.exec('COMMIT');
1097
+ }
1098
+ catch (err) {
1099
+ this.db.exec('ROLLBACK');
1100
+ throw err;
1101
+ }
1102
+ return rows.length;
1103
+ }
1104
+ // ── Read operations ─────────────────────────────────────────────────────────
1105
+ findCallers(symbolName, limit) {
1106
+ const hasLimit = typeof limit === 'number' && limit > 0;
1107
+ const sql = hasLimit
1108
+ ? `
1109
+ SELECT
1110
+ s.name AS callerName,
1111
+ s.qualified_name AS callerQualifiedName,
1112
+ s.kind AS callerKind,
1113
+ f.path AS callerFile,
1114
+ e.line AS callerLine,
1115
+ e.kind AS edgeKind
1116
+ FROM edges e
1117
+ JOIN symbols s ON s.id = e.from_id
1118
+ JOIN files f ON f.id = s.file_id
1119
+ WHERE e.to_name = ? AND e.kind = 'call'
1120
+ LIMIT ?
1121
+ `
1122
+ : `
1123
+ SELECT
1124
+ s.name AS callerName,
1125
+ s.qualified_name AS callerQualifiedName,
1126
+ s.kind AS callerKind,
1127
+ f.path AS callerFile,
1128
+ e.line AS callerLine,
1129
+ e.kind AS edgeKind
1130
+ FROM edges e
1131
+ JOIN symbols s ON s.id = e.from_id
1132
+ JOIN files f ON f.id = s.file_id
1133
+ WHERE e.to_name = ? AND e.kind = 'call'
1134
+ ORDER BY f.path, e.line
1135
+ `;
1136
+ const stmt = this.db.prepare(sql);
1137
+ const rows = (hasLimit
1138
+ ? stmt.all(symbolName, limit)
1139
+ : stmt.all(symbolName));
1140
+ const out = rows.map(r => ({
1141
+ callerName: toStr(r.callerName),
1142
+ callerQualifiedName: toNullStr(r.callerQualifiedName),
1143
+ callerKind: toStr(r.callerKind),
1144
+ callerFile: toStr(r.callerFile),
1145
+ callerLine: toNum(r.callerLine),
1146
+ edgeKind: toStr(r.edgeKind),
1147
+ }));
1148
+ if (hasLimit) {
1149
+ out.sort((a, b) => a.callerFile < b.callerFile ? -1 :
1150
+ a.callerFile > b.callerFile ? 1 :
1151
+ a.callerLine - b.callerLine);
1152
+ }
1153
+ return out;
1154
+ }
1155
+ countCallers(symbolName) {
1156
+ const row = this.db.prepare("SELECT COUNT(*) AS c FROM edges WHERE to_name = ? AND kind = 'call'").get(symbolName);
1157
+ return toNum(row.c);
1158
+ }
1159
+ /**
1160
+ * Callers of a specific symbol id — never collapses short-name siblings.
1161
+ * Track E + any tool that already has a resolved symbol id should use
1162
+ * this instead of `findCallers(name)`. Edges whose `to_id` is NULL
1163
+ * (unresolved) are intentionally skipped: with no resolved id we can't
1164
+ * tell whether they target THIS specific symbol vs. a same-short-name
1165
+ * sibling, and Track E callers want id-specificity.
1166
+ */
1167
+ findCallersById(symbolId, limit) {
1168
+ const hasLimit = typeof limit === 'number' && limit > 0;
1169
+ const sql = hasLimit
1170
+ ? `
1171
+ SELECT
1172
+ s.name AS callerName,
1173
+ s.qualified_name AS callerQualifiedName,
1174
+ s.kind AS callerKind,
1175
+ f.path AS callerFile,
1176
+ e.line AS callerLine,
1177
+ e.kind AS edgeKind
1178
+ FROM edges e
1179
+ JOIN symbols s ON s.id = e.from_id
1180
+ JOIN files f ON f.id = s.file_id
1181
+ WHERE e.to_id = ? AND e.kind = 'call'
1182
+ LIMIT ?
1183
+ `
1184
+ : `
1185
+ SELECT
1186
+ s.name AS callerName,
1187
+ s.qualified_name AS callerQualifiedName,
1188
+ s.kind AS callerKind,
1189
+ f.path AS callerFile,
1190
+ e.line AS callerLine,
1191
+ e.kind AS edgeKind
1192
+ FROM edges e
1193
+ JOIN symbols s ON s.id = e.from_id
1194
+ JOIN files f ON f.id = s.file_id
1195
+ WHERE e.to_id = ? AND e.kind = 'call'
1196
+ ORDER BY f.path, e.line
1197
+ `;
1198
+ const stmt = this.db.prepare(sql);
1199
+ const rows = (hasLimit ? stmt.all(symbolId, limit) : stmt.all(symbolId));
1200
+ const out = rows.map(r => ({
1201
+ callerName: toStr(r.callerName),
1202
+ callerQualifiedName: toNullStr(r.callerQualifiedName),
1203
+ callerKind: toStr(r.callerKind),
1204
+ callerFile: toStr(r.callerFile),
1205
+ callerLine: toNum(r.callerLine),
1206
+ edgeKind: toStr(r.edgeKind),
1207
+ }));
1208
+ if (hasLimit) {
1209
+ out.sort((a, b) => a.callerFile < b.callerFile ? -1 :
1210
+ a.callerFile > b.callerFile ? 1 :
1211
+ a.callerLine - b.callerLine);
1212
+ }
1213
+ return out;
1214
+ }
1215
+ /** Count of callers for a specific symbol id (id-scoped). */
1216
+ countCallersById(symbolId) {
1217
+ const row = this.db.prepare("SELECT COUNT(*) AS c FROM edges WHERE to_id = ? AND kind = 'call'").get(symbolId);
1218
+ return toNum(row.c);
1219
+ }
1220
+ /**
1221
+ * Callees emitted by a specific caller symbol id — never collapses
1222
+ * short-name siblings the way `findCallees(name)` does. Returns one row
1223
+ * per call edge.
1224
+ */
1225
+ findCalleesById(symbolId) {
1226
+ const rows = this.db.prepare(`
1227
+ SELECT
1228
+ e.to_name AS calleeName,
1229
+ s2.kind AS calleeKind,
1230
+ f2.path AS calleeFile,
1231
+ s2.line_start AS calleeLineStart,
1232
+ e.kind AS edgeKind
1233
+ FROM edges e
1234
+ LEFT JOIN symbols s2 ON s2.id = e.to_id
1235
+ LEFT JOIN files f2 ON f2.id = s2.file_id
1236
+ WHERE e.from_id = ? AND e.kind = 'call'
1237
+ ORDER BY e.line
1238
+ `).all(symbolId);
1239
+ return rows.map(r => ({
1240
+ calleeName: toStr(r.calleeName),
1241
+ calleeKind: toNullStr(r.calleeKind),
1242
+ calleeFile: toNullStr(r.calleeFile),
1243
+ calleeLineStart: toNullNum(r.calleeLineStart),
1244
+ edgeKind: toStr(r.edgeKind),
1245
+ }));
1246
+ }
1247
+ findCallees(symbolName) {
1248
+ const rows = this.db.prepare(`
1249
+ SELECT
1250
+ e.to_name AS calleeName,
1251
+ s2.kind AS calleeKind,
1252
+ f2.path AS calleeFile,
1253
+ s2.line_start AS calleeLineStart,
1254
+ e.kind AS edgeKind
1255
+ FROM edges e
1256
+ JOIN symbols s ON s.id = e.from_id
1257
+ LEFT JOIN symbols s2 ON s2.id = e.to_id
1258
+ LEFT JOIN files f2 ON f2.id = s2.file_id
1259
+ WHERE s.name = ? AND e.kind = 'call'
1260
+ ORDER BY e.line
1261
+ `).all(symbolName);
1262
+ return rows.map(r => ({
1263
+ calleeName: toStr(r.calleeName),
1264
+ calleeKind: toNullStr(r.calleeKind),
1265
+ calleeFile: toNullStr(r.calleeFile),
1266
+ calleeLineStart: toNullNum(r.calleeLineStart),
1267
+ edgeKind: toStr(r.edgeKind),
1268
+ }));
1269
+ }
1270
+ /**
1271
+ * Build the predicate suffix shared by findSymbols / getDefinition /
1272
+ * getTopSymbols / countSymbols. Returns the `AND …` string that augments a
1273
+ * WHERE clause; never starts the WHERE itself so callers control the rest.
1274
+ */
1275
+ filterClauseFromOptions(opts) {
1276
+ const f = resolveSearchFlags(opts);
1277
+ return buildRoleFilter('f.', f.includeVendor, f.includeGenerated, this.hasRoleColumns, {
1278
+ symbolPrefix: 's.',
1279
+ includeTests: f.includeTests,
1280
+ includeDeclarations: f.includeDeclarations,
1281
+ includeTypeRefs: f.includeTypeRefs,
1282
+ hasSymbolRoleColumn: this.hasSymbolRoleColumn,
1283
+ });
1284
+ }
1285
+ findSymbols(name, options = {}) {
1286
+ const limit = Math.max(1, options.limit ?? 50);
1287
+ const filter = this.filterClauseFromOptions(options);
1288
+ const rows = this.db.prepare(`
1289
+ SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
1290
+ FROM symbols s JOIN files f ON f.id = s.file_id
1291
+ WHERE (s.name LIKE ? OR s.qualified_name LIKE ?)
1292
+ ${filter}
1293
+ ORDER BY s.pagerank DESC
1294
+ LIMIT ?
1295
+ `).all(`%${name}%`, `%${name}%`, limit);
1296
+ return rows.map(toSymbolRow);
1297
+ }
1298
+ /**
1299
+ * FTS5 search across symbol name / qualified_name / signature / split form.
1300
+ * Falls back to `findSymbols` (LIKE) when FTS5 isn't available or returns
1301
+ * nothing. Returns BM25-ranked results.
1302
+ */
1303
+ searchSymbolsFts(query, options = {}) {
1304
+ const limit = Math.max(1, options.limit ?? 50);
1305
+ if (!this.hasV4Tables)
1306
+ return this.findSymbols(query, options);
1307
+ const matchExpr = ftsQuery(query);
1308
+ if (!matchExpr)
1309
+ return this.findSymbols(query, options);
1310
+ const filter = this.filterClauseFromOptions(options);
1311
+ try {
1312
+ const rows = this.db.prepare(`
1313
+ SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)},
1314
+ bm25(symbols_fts) AS rank
1315
+ FROM symbols_fts
1316
+ JOIN symbols s ON s.id = symbols_fts.rowid
1317
+ JOIN files f ON f.id = s.file_id
1318
+ WHERE symbols_fts MATCH ?
1319
+ ${filter}
1320
+ ORDER BY rank, s.pagerank DESC
1321
+ LIMIT ?
1322
+ `).all(matchExpr, limit);
1323
+ if (rows.length > 0)
1324
+ return rows.map(toSymbolRow);
1325
+ }
1326
+ catch { /* fall through */ }
1327
+ return this.findSymbols(query, options);
1328
+ }
1329
+ /**
1330
+ * FTS5 search over file paths. Returns matching files ranked by BM25.
1331
+ */
1332
+ searchFilesFts(query, limit = 30, options = {}) {
1333
+ if (!this.hasV4Tables)
1334
+ return [];
1335
+ const matchExpr = ftsQuery(query);
1336
+ if (!matchExpr)
1337
+ return [];
1338
+ const includeTests = options.includeTests ?? false;
1339
+ const includeVendor = options.includeVendor ?? false;
1340
+ const includeGenerated = options.includeGenerated ?? false;
1341
+ try {
1342
+ const rows = this.db.prepare(`
1343
+ SELECT f.id, f.path, f.rel_path AS relPath, f.language, f.role
1344
+ FROM files_fts
1345
+ JOIN files f ON f.id = files_fts.rowid
1346
+ WHERE files_fts MATCH ?
1347
+ ORDER BY bm25(files_fts)
1348
+ LIMIT ?
1349
+ `).all(matchExpr, limit * 2);
1350
+ return rows
1351
+ .map(r => ({
1352
+ id: toNum(r.id),
1353
+ path: toStr(r.path),
1354
+ relPath: toStr(r.relPath),
1355
+ language: toStr(r.language),
1356
+ role: toStr(r.role),
1357
+ }))
1358
+ .filter(f => (includeVendor || f.role !== 'vendor') &&
1359
+ (includeGenerated || f.role !== 'generated') &&
1360
+ (includeTests || f.role !== 'test'))
1361
+ .slice(0, limit);
1362
+ }
1363
+ catch {
1364
+ return [];
1365
+ }
1366
+ }
1367
+ listSymbolsInFile(filePath, limit = 200) {
1368
+ const rows = this.db.prepare(`
1369
+ SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
1370
+ FROM symbols s JOIN files f ON f.id = s.file_id
1371
+ WHERE f.path = ? OR f.rel_path = ?
1372
+ ORDER BY s.line_start
1373
+ LIMIT ?
1374
+ `).all(filePath, filePath, limit);
1375
+ return rows.map(toSymbolRow);
1376
+ }
1377
+ getTopSymbols(limit = 20, options = {}) {
1378
+ const filter = this.filterClauseFromOptions(options);
1379
+ const where = filter ? `WHERE ${filter.replace(/^AND\s+/, '')}` : '';
1380
+ const rows = this.db.prepare(`
1381
+ SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
1382
+ FROM symbols s JOIN files f ON f.id = s.file_id
1383
+ ${where}
1384
+ ORDER BY s.pagerank DESC
1385
+ LIMIT ?
1386
+ `).all(limit);
1387
+ return rows.map(toSymbolRow);
1388
+ }
1389
+ getDefinition(name, options = {}) {
1390
+ const filter = this.filterClauseFromOptions(options);
1391
+ // File disambiguation accepts an absolute path, the exact rel_path, OR a
1392
+ // trailing path fragment on a segment boundary (`weird.c` matches
1393
+ // `src/weird.c`; `auth/service.ts` matches `packages/api/auth/service.ts`).
1394
+ // Without this an agent had to know the full rel_path or the filter
1395
+ // silently returned nothing — a wasted round-trip. Matching stays
1396
+ // deterministic: the fragment must align to a `/` boundary (so `auth.ts`
1397
+ // never matches `oauth.ts`), and LIKE metacharacters are escaped so a `_`
1398
+ // in a filename can't act as a wildcard.
1399
+ const fp = options.filePath;
1400
+ let fileClause = '';
1401
+ let fileArgs = [];
1402
+ if (fp) {
1403
+ const norm = fp.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
1404
+ const suffix = '%/' + escapeLike(norm);
1405
+ fileClause = 'AND (f.path = ? OR f.rel_path = ? OR f.rel_path LIKE ? ESCAPE \'\\\')';
1406
+ fileArgs = [fp, norm, suffix];
1407
+ }
1408
+ const stmt = this.db.prepare(`
1409
+ SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
1410
+ FROM symbols s JOIN files f ON f.id = s.file_id
1411
+ WHERE (s.name = ? OR s.qualified_name = ?)
1412
+ ${filter}
1413
+ ${fileClause}
1414
+ ORDER BY s.pagerank DESC
1415
+ LIMIT 50
1416
+ `);
1417
+ const rows = stmt.all(name, name, ...fileArgs);
1418
+ return rows.map(toSymbolRow);
1419
+ }
1420
+ getSymbolById(id) {
1421
+ const row = this.db.prepare(`
1422
+ SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
1423
+ FROM symbols s JOIN files f ON f.id = s.file_id
1424
+ WHERE s.id = ?
1425
+ `).get(id);
1426
+ return row ? toSymbolRow(row) : null;
1427
+ }
1428
+ countSymbols(name, options = {}) {
1429
+ const filter = this.filterClauseFromOptions(options);
1430
+ const row = this.db.prepare(`
1431
+ SELECT COUNT(*) AS c
1432
+ FROM symbols s JOIN files f ON f.id = s.file_id
1433
+ WHERE (s.name LIKE ? OR s.qualified_name LIKE ?) ${filter}
1434
+ `).get(`%${name}%`, `%${name}%`);
1435
+ return toNum(row.c);
1436
+ }
1437
+ listFiles() {
1438
+ const rows = this.db.prepare(`
1439
+ SELECT id, path, rel_path AS relPath, language, hash, indexed_at AS indexedAt,
1440
+ role, is_vendor AS isVendor, is_generated AS isGenerated
1441
+ FROM files
1442
+ `).all();
1443
+ return rows.map(r => ({
1444
+ id: toNum(r.id),
1445
+ path: toStr(r.path),
1446
+ relPath: toStr(r.relPath),
1447
+ language: toStr(r.language),
1448
+ hash: toStr(r.hash),
1449
+ indexedAt: toNum(r.indexedAt),
1450
+ role: toStr(r.role),
1451
+ isVendor: toNum(r.isVendor),
1452
+ isGenerated: toNum(r.isGenerated),
1453
+ }));
1454
+ }
1455
+ getRoleCounts() {
1456
+ const out = { project: 0, vendor: 0, generated: 0, test: 0 };
1457
+ try {
1458
+ const rows = this.db.prepare('SELECT role, COUNT(*) AS c FROM files GROUP BY role').all();
1459
+ for (const r of rows) {
1460
+ const role = toStr(r.role);
1461
+ if (role in out)
1462
+ out[role] = toNum(r.c);
1463
+ }
1464
+ }
1465
+ catch { /* */ }
1466
+ return out;
1467
+ }
1468
+ // ── Routes ──────────────────────────────────────────────────────────────────
1469
+ listRoutes(options = {}) {
1470
+ if (!this.hasV4Tables)
1471
+ return [];
1472
+ const hasProtocol = this.hasColumn('routes', 'protocol');
1473
+ const where = [];
1474
+ const args = [];
1475
+ if (options.method) {
1476
+ where.push('r.method = ?');
1477
+ args.push(options.method.toUpperCase());
1478
+ }
1479
+ if (options.pathSubstr) {
1480
+ where.push('r.path LIKE ?');
1481
+ args.push(`%${options.pathSubstr}%`);
1482
+ }
1483
+ if (options.framework) {
1484
+ where.push('r.framework = ?');
1485
+ args.push(options.framework);
1486
+ }
1487
+ if (hasProtocol) {
1488
+ if (options.protocol) {
1489
+ where.push('r.protocol = ?');
1490
+ args.push(options.protocol);
1491
+ }
1492
+ if (options.operation) {
1493
+ where.push('r.operation = ?');
1494
+ args.push(options.operation);
1495
+ }
1496
+ if (options.topic) {
1497
+ where.push('r.topic = ?');
1498
+ args.push(options.topic);
1499
+ }
1500
+ if (options.queue) {
1501
+ where.push('r.queue = ?');
1502
+ args.push(options.queue);
1503
+ }
1504
+ if (options.service) {
1505
+ where.push('r.service = ?');
1506
+ args.push(options.service);
1507
+ }
1508
+ }
1509
+ const limit = options.limit ?? 200;
1510
+ const protocolCols = hasProtocol
1511
+ ? ', r.protocol, r.operation, r.topic, r.queue, r.exchange, r.service, r.broker, r.metadata_json AS metadataJson'
1512
+ : '';
1513
+ const sql = `
1514
+ SELECT r.id, r.method, r.path, r.framework, r.handler_name AS handlerName,
1515
+ r.handler_id AS handlerId,
1516
+ s.qualified_name AS handlerSymbol,
1517
+ sf.path AS handlerFile,
1518
+ f.path AS filePath, r.line
1519
+ ${protocolCols}
1520
+ FROM routes r
1521
+ JOIN files f ON f.id = r.file_id
1522
+ LEFT JOIN symbols s ON s.id = r.handler_id
1523
+ LEFT JOIN files sf ON sf.id = s.file_id
1524
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
1525
+ ORDER BY r.path, r.method
1526
+ LIMIT ?
1527
+ `;
1528
+ args.push(limit);
1529
+ const rows = this.db.prepare(sql).all(...args);
1530
+ return rows.map(r => ({
1531
+ id: toNum(r.id),
1532
+ method: toStr(r.method),
1533
+ path: toStr(r.path),
1534
+ framework: toStr(r.framework),
1535
+ handlerName: toNullStr(r.handlerName),
1536
+ handlerId: toNullNum(r.handlerId),
1537
+ handlerSymbol: toNullStr(r.handlerSymbol),
1538
+ handlerFile: toNullStr(r.handlerFile),
1539
+ filePath: toStr(r.filePath),
1540
+ line: toNum(r.line),
1541
+ protocol: hasProtocol ? toNullStr(r.protocol) : null,
1542
+ operation: hasProtocol ? toNullStr(r.operation) : null,
1543
+ topic: hasProtocol ? toNullStr(r.topic) : null,
1544
+ queue: hasProtocol ? toNullStr(r.queue) : null,
1545
+ exchange: hasProtocol ? toNullStr(r.exchange) : null,
1546
+ service: hasProtocol ? toNullStr(r.service) : null,
1547
+ broker: hasProtocol ? toNullStr(r.broker) : null,
1548
+ metadataJson: hasProtocol ? toNullStr(r.metadataJson) : null,
1549
+ }));
1550
+ }
1551
+ countRoutes() {
1552
+ if (!this.hasV4Tables)
1553
+ return 0;
1554
+ const row = this.db.prepare('SELECT COUNT(*) AS c FROM routes').get();
1555
+ return toNum(row.c);
1556
+ }
1557
+ // ── v8 Track-G service calls + links ────────────────────────────────────
1558
+ /** Total count of service_calls rows. */
1559
+ countServiceCalls() {
1560
+ try {
1561
+ const row = this.db.prepare('SELECT COUNT(*) AS c FROM service_calls').get();
1562
+ return toNum(row.c);
1563
+ }
1564
+ catch {
1565
+ return 0;
1566
+ }
1567
+ }
1568
+ /** Total count of service_links rows. */
1569
+ countServiceLinks() {
1570
+ try {
1571
+ const row = this.db.prepare('SELECT COUNT(*) AS c FROM service_links').get();
1572
+ return toNum(row.c);
1573
+ }
1574
+ catch {
1575
+ return 0;
1576
+ }
1577
+ }
1578
+ /** List service_calls with the AST-attributed caller joined in. */
1579
+ listServiceCalls(options = {}) {
1580
+ const where = [];
1581
+ const args = [];
1582
+ if (options.protocol) {
1583
+ where.push('sc.protocol = ?');
1584
+ args.push(options.protocol);
1585
+ }
1586
+ if (options.method) {
1587
+ where.push('sc.method = ?');
1588
+ args.push(options.method.toUpperCase());
1589
+ }
1590
+ if (options.framework) {
1591
+ where.push('sc.framework = ?');
1592
+ args.push(options.framework);
1593
+ }
1594
+ if (options.pathSubstr) {
1595
+ where.push('sc.normalized_path LIKE ?');
1596
+ args.push(`%${options.pathSubstr}%`);
1597
+ }
1598
+ if (options.callerSymbolId != null) {
1599
+ where.push('sc.symbol_id = ?');
1600
+ args.push(options.callerSymbolId);
1601
+ }
1602
+ if (options.minConfidence != null) {
1603
+ where.push('sc.confidence >= ?');
1604
+ args.push(options.minConfidence);
1605
+ }
1606
+ if (options.operation) {
1607
+ where.push('sc.operation = ?');
1608
+ args.push(options.operation);
1609
+ }
1610
+ if (options.topic) {
1611
+ where.push('sc.topic = ?');
1612
+ args.push(options.topic);
1613
+ }
1614
+ if (options.queue) {
1615
+ where.push('sc.queue = ?');
1616
+ args.push(options.queue);
1617
+ }
1618
+ if (options.service) {
1619
+ where.push('sc.service = ?');
1620
+ args.push(options.service);
1621
+ }
1622
+ const limit = Math.min(options.limit ?? 100, 1000);
1623
+ const offset = options.offset ?? 0;
1624
+ args.push(limit, offset);
1625
+ const sql = `
1626
+ SELECT sc.id, sc.protocol, sc.method, sc.raw_target AS rawTarget,
1627
+ sc.normalized_path AS normalizedPath, sc.host_hint AS hostHint,
1628
+ sc.env_key AS envKey, sc.framework, sc.line, sc.confidence,
1629
+ sc.operation, sc.topic, sc.queue, sc.exchange, sc.service,
1630
+ sc.broker, sc.metadata_json AS metadataJson,
1631
+ f.rel_path AS filePath,
1632
+ sc.symbol_id AS callerSymbolId,
1633
+ s.name AS callerName, s.qualified_name AS callerQualifiedName,
1634
+ s.kind AS callerKind
1635
+ FROM service_calls sc
1636
+ JOIN files f ON f.id = sc.file_id
1637
+ LEFT JOIN symbols s ON s.id = sc.symbol_id
1638
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
1639
+ ORDER BY sc.id ASC
1640
+ LIMIT ? OFFSET ?
1641
+ `;
1642
+ try {
1643
+ const rows = this.db.prepare(sql).all(...args);
1644
+ return rows.map(r => ({
1645
+ id: toNum(r.id),
1646
+ protocol: toStr(r.protocol),
1647
+ method: toNullStr(r.method),
1648
+ rawTarget: toStr(r.rawTarget),
1649
+ normalizedPath: toNullStr(r.normalizedPath),
1650
+ hostHint: toNullStr(r.hostHint),
1651
+ envKey: toNullStr(r.envKey),
1652
+ framework: toStr(r.framework),
1653
+ line: toNum(r.line),
1654
+ confidence: Number(r.confidence ?? 0),
1655
+ filePath: toStr(r.filePath),
1656
+ callerSymbolId: r.callerSymbolId == null ? null : toNum(r.callerSymbolId),
1657
+ callerName: toNullStr(r.callerName),
1658
+ callerQualifiedName: toNullStr(r.callerQualifiedName),
1659
+ callerKind: toNullStr(r.callerKind),
1660
+ operation: toNullStr(r.operation),
1661
+ topic: toNullStr(r.topic),
1662
+ queue: toNullStr(r.queue),
1663
+ exchange: toNullStr(r.exchange),
1664
+ service: toNullStr(r.service),
1665
+ broker: toNullStr(r.broker),
1666
+ metadataJson: toNullStr(r.metadataJson),
1667
+ }));
1668
+ }
1669
+ catch {
1670
+ return [];
1671
+ }
1672
+ }
1673
+ /** List service_links with caller + handler + route joined in. */
1674
+ listServiceLinks(options = {}) {
1675
+ const where = [];
1676
+ const args = [];
1677
+ if (options.protocol) {
1678
+ where.push('sl.protocol = ?');
1679
+ args.push(options.protocol);
1680
+ }
1681
+ if (options.matchKind) {
1682
+ where.push('sl.match_kind = ?');
1683
+ args.push(options.matchKind);
1684
+ }
1685
+ if (options.minConfidence != null) {
1686
+ where.push('sl.confidence >= ?');
1687
+ args.push(options.minConfidence);
1688
+ }
1689
+ if (options.callerSymbolId != null) {
1690
+ where.push('sl.caller_symbol_id = ?');
1691
+ args.push(options.callerSymbolId);
1692
+ }
1693
+ if (options.handlerSymbolId != null) {
1694
+ where.push('sl.handler_symbol_id = ?');
1695
+ args.push(options.handlerSymbolId);
1696
+ }
1697
+ if (options.method) {
1698
+ where.push('sc.method = ?');
1699
+ args.push(options.method.toUpperCase());
1700
+ }
1701
+ if (options.pathSubstr) {
1702
+ where.push('(sc.normalized_path LIKE ? OR r.path LIKE ?)');
1703
+ args.push(`%${options.pathSubstr}%`, `%${options.pathSubstr}%`);
1704
+ }
1705
+ const limit = Math.min(options.limit ?? 100, 1000);
1706
+ const offset = options.offset ?? 0;
1707
+ args.push(limit, offset);
1708
+ const sql = `
1709
+ SELECT sl.id, sl.call_id AS callId, sl.route_id AS routeId,
1710
+ sl.protocol, sl.match_kind AS matchKind,
1711
+ sl.confidence, sl.evidence_json AS evidenceJson,
1712
+ sl.caller_symbol_id AS callerSymbolId,
1713
+ cs.name AS callerName, cs.qualified_name AS callerQualifiedName,
1714
+ cf.rel_path AS callerFile,
1715
+ sc.line AS callerLine,
1716
+ sc.method AS callMethod, sc.raw_target AS callRawTarget,
1717
+ sc.normalized_path AS callNormalizedPath, sc.framework AS callFramework,
1718
+ sc.env_key AS callEnvKey, sc.host_hint AS callHostHint,
1719
+ sc.operation AS callOperation, sc.topic AS callTopic,
1720
+ sc.queue AS callQueue, sc.service AS callService,
1721
+ sl.handler_symbol_id AS handlerSymbolId,
1722
+ hs.name AS handlerName, hs.qualified_name AS handlerQualifiedName,
1723
+ hf.rel_path AS handlerFile, hs.line_start AS handlerLine,
1724
+ r.method AS routeMethod, r.path AS routePath, r.framework AS routeFramework,
1725
+ r.operation AS routeOperation, r.topic AS routeTopic,
1726
+ r.queue AS routeQueue, r.service AS routeService
1727
+ FROM service_links sl
1728
+ LEFT JOIN service_calls sc ON sc.id = sl.call_id
1729
+ LEFT JOIN files cf ON cf.id = sc.file_id
1730
+ LEFT JOIN symbols cs ON cs.id = sl.caller_symbol_id
1731
+ LEFT JOIN symbols hs ON hs.id = sl.handler_symbol_id
1732
+ LEFT JOIN files hf ON hf.id = hs.file_id
1733
+ LEFT JOIN routes r ON r.id = sl.route_id
1734
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
1735
+ ORDER BY sl.id ASC
1736
+ LIMIT ? OFFSET ?
1737
+ `;
1738
+ try {
1739
+ const rows = this.db.prepare(sql).all(...args);
1740
+ return rows.map(r => ({
1741
+ id: toNum(r.id),
1742
+ callId: toNum(r.callId),
1743
+ routeId: r.routeId == null ? null : toNum(r.routeId),
1744
+ protocol: toStr(r.protocol),
1745
+ matchKind: toStr(r.matchKind),
1746
+ confidence: Number(r.confidence ?? 0),
1747
+ evidenceJson: toStr(r.evidenceJson),
1748
+ callerSymbolId: r.callerSymbolId == null ? null : toNum(r.callerSymbolId),
1749
+ callerName: toNullStr(r.callerName),
1750
+ callerQualifiedName: toNullStr(r.callerQualifiedName),
1751
+ callerFile: toNullStr(r.callerFile),
1752
+ callerLine: toNum(r.callerLine ?? 0),
1753
+ callMethod: toNullStr(r.callMethod),
1754
+ callRawTarget: toStr(r.callRawTarget),
1755
+ callNormalizedPath: toNullStr(r.callNormalizedPath),
1756
+ callFramework: toStr(r.callFramework),
1757
+ callEnvKey: toNullStr(r.callEnvKey),
1758
+ callHostHint: toNullStr(r.callHostHint),
1759
+ callOperation: toNullStr(r.callOperation),
1760
+ callTopic: toNullStr(r.callTopic),
1761
+ callQueue: toNullStr(r.callQueue),
1762
+ callService: toNullStr(r.callService),
1763
+ handlerSymbolId: r.handlerSymbolId == null ? null : toNum(r.handlerSymbolId),
1764
+ handlerName: toNullStr(r.handlerName),
1765
+ handlerQualifiedName: toNullStr(r.handlerQualifiedName),
1766
+ handlerFile: toNullStr(r.handlerFile),
1767
+ handlerLine: r.handlerLine == null ? null : toNum(r.handlerLine),
1768
+ routeMethod: toNullStr(r.routeMethod),
1769
+ routePath: toNullStr(r.routePath),
1770
+ routeFramework: toNullStr(r.routeFramework),
1771
+ routeOperation: toNullStr(r.routeOperation),
1772
+ routeTopic: toNullStr(r.routeTopic),
1773
+ routeQueue: toNullStr(r.routeQueue),
1774
+ routeService: toNullStr(r.routeService),
1775
+ }));
1776
+ }
1777
+ catch {
1778
+ return [];
1779
+ }
1780
+ }
1781
+ /** id-scoped helper: every service_link whose caller is symbolId. */
1782
+ serviceLinksForCaller(symbolId, options = {}) {
1783
+ return this.listServiceLinks({ callerSymbolId: symbolId, limit: options.limit });
1784
+ }
1785
+ /** id-scoped helper: every service_link whose handler is symbolId. */
1786
+ serviceLinksForHandler(symbolId, options = {}) {
1787
+ return this.listServiceLinks({ handlerSymbolId: symbolId, limit: options.limit });
1788
+ }
1789
+ /**
1790
+ * Bounded BFS over service_links from caller to handler. Treats each
1791
+ * service_link as a directed edge `caller_symbol_id → handler_symbol_id`.
1792
+ * Returns the shortest path as an array of symbol ids, or [] if unreachable
1793
+ * within maxDepth. Combines with the normal call-graph trace done by
1794
+ * `tracePath`; this one is service-link only.
1795
+ */
1796
+ traceServicePath(fromSymbolId, toSymbolId, maxDepth = 6) {
1797
+ if (fromSymbolId === toSymbolId)
1798
+ return [fromSymbolId];
1799
+ if (maxDepth <= 0)
1800
+ return [];
1801
+ try {
1802
+ const stmt = this.db.prepare(`SELECT DISTINCT handler_symbol_id AS h
1803
+ FROM service_links
1804
+ WHERE caller_symbol_id = ? AND handler_symbol_id IS NOT NULL`);
1805
+ const parents = new Map();
1806
+ const visited = new Set([fromSymbolId]);
1807
+ let frontier = [fromSymbolId];
1808
+ for (let depth = 0; depth < maxDepth; depth++) {
1809
+ const next = [];
1810
+ for (const cur of frontier) {
1811
+ const rows = stmt.all(cur);
1812
+ for (const r of rows) {
1813
+ const h = toNum(r.h);
1814
+ if (visited.has(h))
1815
+ continue;
1816
+ visited.add(h);
1817
+ parents.set(h, cur);
1818
+ if (h === toSymbolId) {
1819
+ // Reconstruct path
1820
+ const path = [h];
1821
+ let cursor = cur;
1822
+ while (cursor !== fromSymbolId) {
1823
+ path.push(cursor);
1824
+ cursor = parents.get(cursor);
1825
+ }
1826
+ path.push(fromSymbolId);
1827
+ path.reverse();
1828
+ return path;
1829
+ }
1830
+ next.push(h);
1831
+ }
1832
+ }
1833
+ if (next.length === 0)
1834
+ break;
1835
+ frontier = next;
1836
+ }
1837
+ return [];
1838
+ }
1839
+ catch {
1840
+ return [];
1841
+ }
1842
+ }
1843
+ /**
1844
+ * v9 Track-H — bounded service-link traversal from a single symbol.
1845
+ *
1846
+ * Walks the directed service-link graph starting at `fromSymbolId`. Each
1847
+ * step follows `caller_symbol_id → handler_symbol_id` edges, recording the
1848
+ * protocol / matchKind / hop chain for every reachable handler.
1849
+ *
1850
+ * Bounds (all configurable; defaults are conservative):
1851
+ * - maxDepth limit hops away from the source (default 4)
1852
+ * - maxNodes stop after expanding this many handlers (default 200)
1853
+ * - maxFanout stop expanding a node after this many outgoing service
1854
+ * links (default 20)
1855
+ *
1856
+ * Returns one record per reached handler with the protocols and match-kinds
1857
+ * encountered along the path; `cutoff` flags the limit that fired (if any).
1858
+ */
1859
+ traceServiceDependencies(fromSymbolId, options = {}) {
1860
+ const maxDepth = options.maxDepth ?? 4;
1861
+ const maxNodes = options.maxNodes ?? 200;
1862
+ const maxFanout = options.maxFanout ?? 20;
1863
+ const reached = new Map();
1864
+ let cutoff = null;
1865
+ let expanded = 0;
1866
+ try {
1867
+ // Deterministic ordering by handler symbol id ASC inside each step.
1868
+ const stmt = this.db.prepare(`SELECT handler_symbol_id AS h, protocol AS p, match_kind AS mk
1869
+ FROM service_links
1870
+ WHERE caller_symbol_id = ? AND handler_symbol_id IS NOT NULL
1871
+ ORDER BY confidence DESC, handler_symbol_id ASC
1872
+ LIMIT ?`);
1873
+ let frontier = [fromSymbolId];
1874
+ let maxDepthFrontier = [];
1875
+ reached.set(fromSymbolId, { depth: 0, protocols: new Set(), matchKinds: new Set(), parent: null });
1876
+ for (let depth = 0; depth < maxDepth; depth++) {
1877
+ const next = [];
1878
+ for (const cur of frontier) {
1879
+ // +1 so we can detect fanout-cap hits cleanly (over-by-one).
1880
+ const rows = stmt.all(cur, maxFanout + 1);
1881
+ if (rows.length > maxFanout)
1882
+ cutoff = 'maxFanout';
1883
+ for (let i = 0; i < Math.min(rows.length, maxFanout); i++) {
1884
+ const h = toNum(rows[i].h);
1885
+ const p = toStr(rows[i].p);
1886
+ const mk = toStr(rows[i].mk);
1887
+ if (reached.has(h)) {
1888
+ const entry = reached.get(h);
1889
+ entry.protocols.add(p);
1890
+ entry.matchKinds.add(mk);
1891
+ continue;
1892
+ }
1893
+ reached.set(h, {
1894
+ depth: depth + 1,
1895
+ protocols: new Set([p]),
1896
+ matchKinds: new Set([mk]),
1897
+ parent: cur,
1898
+ });
1899
+ next.push(h);
1900
+ if (reached.size > maxNodes) {
1901
+ cutoff = 'maxNodes';
1902
+ break;
1903
+ }
1904
+ }
1905
+ expanded++;
1906
+ if (cutoff === 'maxNodes')
1907
+ break;
1908
+ }
1909
+ if (cutoff === 'maxNodes')
1910
+ break;
1911
+ if (next.length === 0)
1912
+ break;
1913
+ if (depth + 1 >= maxDepth) {
1914
+ maxDepthFrontier = next;
1915
+ break;
1916
+ }
1917
+ frontier = next;
1918
+ }
1919
+ if (!cutoff && reached.size >= maxNodes)
1920
+ cutoff = 'maxNodes';
1921
+ if (!cutoff && maxDepthFrontier.length > 0) {
1922
+ const placeholders = maxDepthFrontier.map(() => '?').join(',');
1923
+ const row = this.db.prepare(`SELECT 1 AS ok
1924
+ FROM service_links
1925
+ WHERE caller_symbol_id IN (${placeholders})
1926
+ AND handler_symbol_id IS NOT NULL
1927
+ LIMIT 1`).get(...maxDepthFrontier);
1928
+ if (row)
1929
+ cutoff = 'maxDepth';
1930
+ }
1931
+ }
1932
+ catch { /* fall through with what we have */ }
1933
+ // Build hop chains for each reached handler.
1934
+ const out = [];
1935
+ for (const [id, entry] of reached) {
1936
+ if (id === fromSymbolId)
1937
+ continue;
1938
+ const hops = [id];
1939
+ let p = entry.parent;
1940
+ while (p !== null && p !== fromSymbolId) {
1941
+ hops.push(p);
1942
+ p = reached.get(p)?.parent ?? null;
1943
+ }
1944
+ hops.push(fromSymbolId);
1945
+ hops.reverse();
1946
+ out.push({
1947
+ symbolId: id,
1948
+ depth: entry.depth,
1949
+ protocols: Array.from(entry.protocols).sort(),
1950
+ matchKinds: Array.from(entry.matchKinds).sort(),
1951
+ hops,
1952
+ });
1953
+ }
1954
+ // Deterministic order: by depth ASC, then symbolId ASC.
1955
+ out.sort((a, b) => a.depth - b.depth || a.symbolId - b.symbolId);
1956
+ return { reached: out, cutoff, fromExpanded: expanded };
1957
+ }
1958
+ /**
1959
+ * v9 Track-H — bounded service-link traversal at module granularity.
1960
+ *
1961
+ * Returns the set of modules reachable from `fromModuleId` by following
1962
+ * cross-module service links (one or more service_link edges whose caller
1963
+ * and handler live in different modules). For each reached module the
1964
+ * result includes the minimum hop depth and which protocols carry traffic
1965
+ * into it.
1966
+ *
1967
+ * Useful for "which modules depend on `billing` through HTTP/Kafka/etc?".
1968
+ */
1969
+ traceModuleServiceDependencies(fromModuleId, options = {}) {
1970
+ const maxDepth = options.maxDepth ?? 3;
1971
+ const maxNodes = options.maxNodes ?? 50;
1972
+ if (!this.hasModuleTables)
1973
+ return { reached: [], cutoff: null };
1974
+ const edges = this.db.prepare(`SELECT mm1.module_id AS f, mm2.module_id AS t, sl.protocol AS p, COUNT(*) AS n
1975
+ FROM service_links sl
1976
+ JOIN service_calls sc ON sc.id = sl.call_id
1977
+ JOIN module_members mm1 ON mm1.file_id = sc.file_id
1978
+ JOIN symbols hs ON hs.id = sl.handler_symbol_id
1979
+ JOIN module_members mm2 ON mm2.file_id = hs.file_id
1980
+ WHERE mm1.module_id <> mm2.module_id
1981
+ GROUP BY mm1.module_id, mm2.module_id, sl.protocol
1982
+ ORDER BY mm1.module_id ASC, mm2.module_id ASC, sl.protocol ASC`).all();
1983
+ const adj = new Map();
1984
+ for (const e of edges) {
1985
+ const from = toNum(e.f);
1986
+ const list = adj.get(from) ?? [];
1987
+ list.push({ from, to: toNum(e.t), protocol: toStr(e.p), n: toNum(e.n) });
1988
+ adj.set(from, list);
1989
+ }
1990
+ const reached = new Map();
1991
+ reached.set(fromModuleId, { depth: 0, protocols: new Set(), viaLinks: 0 });
1992
+ let cutoff = null;
1993
+ let frontier = [fromModuleId];
1994
+ let maxDepthFrontier = [];
1995
+ for (let depth = 0; depth < maxDepth; depth++) {
1996
+ const next = [];
1997
+ for (const cur of frontier) {
1998
+ const outs = adj.get(cur) ?? [];
1999
+ for (const e of outs) {
2000
+ if (e.to === fromModuleId)
2001
+ continue;
2002
+ let entry = reached.get(e.to);
2003
+ if (!entry) {
2004
+ entry = { depth: depth + 1, protocols: new Set(), viaLinks: 0 };
2005
+ reached.set(e.to, entry);
2006
+ next.push(e.to);
2007
+ if (reached.size > maxNodes) {
2008
+ cutoff = 'maxNodes';
2009
+ break;
2010
+ }
2011
+ }
2012
+ entry.protocols.add(e.protocol);
2013
+ entry.viaLinks += e.n;
2014
+ }
2015
+ if (cutoff)
2016
+ break;
2017
+ }
2018
+ if (cutoff)
2019
+ break;
2020
+ if (next.length === 0)
2021
+ break;
2022
+ if (depth + 1 >= maxDepth) {
2023
+ maxDepthFrontier = next;
2024
+ break;
2025
+ }
2026
+ frontier = next;
2027
+ }
2028
+ if (!cutoff && maxDepthFrontier.some(id => (adj.get(id)?.length ?? 0) > 0)) {
2029
+ cutoff = 'maxDepth';
2030
+ }
2031
+ const out = [];
2032
+ for (const [id, r] of reached) {
2033
+ if (id === fromModuleId)
2034
+ continue;
2035
+ out.push({
2036
+ moduleId: id, depth: r.depth,
2037
+ protocols: Array.from(r.protocols).sort(),
2038
+ viaLinks: r.viaLinks,
2039
+ });
2040
+ }
2041
+ out.sort((a, b) => a.depth - b.depth || a.moduleId - b.moduleId);
2042
+ return { reached: out, cutoff };
2043
+ }
2044
+ // ── v10 External bundle layers ─────────────────────────────────────────────
2045
+ /** True iff the v10 external/boundary/continuity tables exist on disk. */
2046
+ hasV10() { return this.hasV10Tables; }
2047
+ /** Replace the boundaries / boundary_members / boundary_edges tables.
2048
+ * Atomic — wrapped in a single transaction. */
2049
+ replaceBoundaries(boundaries, edges) {
2050
+ this.assertWritable();
2051
+ if (!this.hasV10Tables)
2052
+ return;
2053
+ this.db.exec('BEGIN');
2054
+ try {
2055
+ this.db.exec('DELETE FROM boundary_edges');
2056
+ this.db.exec('DELETE FROM boundary_members');
2057
+ this.db.exec('DELETE FROM boundaries');
2058
+ const insBoundary = this.db.prepare(`
2059
+ INSERT INTO boundaries
2060
+ (label, kind, root_rel_path, manifest_path, ecosystem, size_files, computed_at)
2061
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2062
+ `);
2063
+ const insMember = this.db.prepare('INSERT OR REPLACE INTO boundary_members (file_id, boundary_id) VALUES (?, ?)');
2064
+ const insEdge = this.db.prepare('INSERT OR REPLACE INTO boundary_edges (from_boundary_id, to_boundary_id, kind, weight) VALUES (?, ?, ?, ?)');
2065
+ const now = Date.now();
2066
+ const indexToId = [];
2067
+ for (const b of boundaries) {
2068
+ const res = insBoundary.run(b.label, b.kind, b.rootRelPath, b.manifestPath, b.ecosystem, b.fileIds.length, now);
2069
+ const id = toNum(res.lastInsertRowid);
2070
+ indexToId.push(id);
2071
+ for (const fid of b.fileIds)
2072
+ insMember.run(fid, id);
2073
+ }
2074
+ for (const e of edges) {
2075
+ const f = indexToId[e.fromIndex];
2076
+ const t = indexToId[e.toIndex];
2077
+ if (f == null || t == null)
2078
+ continue;
2079
+ insEdge.run(f, t, e.kind, e.weight);
2080
+ }
2081
+ this.db.exec('COMMIT');
2082
+ }
2083
+ catch (err) {
2084
+ this.db.exec('ROLLBACK');
2085
+ throw err;
2086
+ }
2087
+ }
2088
+ /** True iff boundaries were populated this build. */
2089
+ hasBoundariesData() {
2090
+ if (!this.hasV10Tables)
2091
+ return false;
2092
+ try {
2093
+ const row = this.db.prepare('SELECT COUNT(*) AS c FROM boundaries').get();
2094
+ return toNum(row.c) > 0;
2095
+ }
2096
+ catch {
2097
+ return false;
2098
+ }
2099
+ }
2100
+ countBoundaries() {
2101
+ if (!this.hasV10Tables)
2102
+ return 0;
2103
+ try {
2104
+ return toNum(this.db.prepare('SELECT COUNT(*) AS c FROM boundaries').get().c);
2105
+ }
2106
+ catch {
2107
+ return 0;
2108
+ }
2109
+ }
2110
+ listBoundaries(limit = 200) {
2111
+ if (!this.hasV10Tables)
2112
+ return [];
2113
+ try {
2114
+ const rows = this.db.prepare(`
2115
+ SELECT id, label, kind, root_rel_path AS rootRelPath,
2116
+ manifest_path AS manifestPath, ecosystem,
2117
+ size_files AS sizeFiles
2118
+ FROM boundaries
2119
+ ORDER BY size_files DESC, label
2120
+ LIMIT ?
2121
+ `).all(limit);
2122
+ return rows.map(r => ({
2123
+ id: toNum(r.id), label: toStr(r.label), kind: toStr(r.kind),
2124
+ rootRelPath: toStr(r.rootRelPath),
2125
+ manifestPath: toNullStr(r.manifestPath),
2126
+ ecosystem: toNullStr(r.ecosystem),
2127
+ sizeFiles: toNum(r.sizeFiles),
2128
+ }));
2129
+ }
2130
+ catch {
2131
+ return [];
2132
+ }
2133
+ }
2134
+ /** Boundary that owns a file id (or null). */
2135
+ boundaryForFile(fileId) {
2136
+ if (!this.hasV10Tables)
2137
+ return null;
2138
+ try {
2139
+ const row = this.db.prepare(`
2140
+ SELECT b.id, b.label, b.kind, b.root_rel_path AS rootRelPath
2141
+ FROM boundary_members bm JOIN boundaries b ON b.id = bm.boundary_id
2142
+ WHERE bm.file_id = ?
2143
+ `).get(fileId);
2144
+ if (!row)
2145
+ return null;
2146
+ return {
2147
+ id: toNum(row.id),
2148
+ label: toStr(row.label),
2149
+ kind: toStr(row.kind),
2150
+ rootRelPath: toStr(row.rootRelPath),
2151
+ };
2152
+ }
2153
+ catch {
2154
+ return null;
2155
+ }
2156
+ }
2157
+ /** Cross-boundary dependency edges from a boundary (outgoing by default). */
2158
+ boundaryDependencies(boundaryId, options = {}) {
2159
+ if (!this.hasV10Tables)
2160
+ return [];
2161
+ const direction = options.direction ?? 'out';
2162
+ const limit = options.limit ?? 100;
2163
+ const sideThis = direction === 'out' ? 'from_boundary_id' : 'to_boundary_id';
2164
+ const sideOther = direction === 'out' ? 'to_boundary_id' : 'from_boundary_id';
2165
+ try {
2166
+ const rows = this.db.prepare(`
2167
+ SELECT b.id AS boundaryId, b.label, be.kind, be.weight
2168
+ FROM boundary_edges be JOIN boundaries b ON b.id = be.${sideOther}
2169
+ WHERE be.${sideThis} = ?
2170
+ ORDER BY be.weight DESC
2171
+ LIMIT ?
2172
+ `).all(boundaryId, limit);
2173
+ return rows.map(r => ({
2174
+ boundaryId: toNum(r.boundaryId),
2175
+ label: toStr(r.label),
2176
+ kind: toStr(r.kind),
2177
+ weight: toNum(r.weight),
2178
+ }));
2179
+ }
2180
+ catch {
2181
+ return [];
2182
+ }
2183
+ }
2184
+ /** For a given symbol id, return the boundaries of each of its callees. */
2185
+ calleeBoundariesOf(symbolId) {
2186
+ if (!this.hasV10Tables)
2187
+ return [];
2188
+ try {
2189
+ const rows = this.db.prepare(`
2190
+ SELECT DISTINCT e.to_id AS calleeId, bm.boundary_id AS boundaryId
2191
+ FROM edges e
2192
+ JOIN symbols s ON s.id = e.to_id
2193
+ JOIN boundary_members bm ON bm.file_id = s.file_id
2194
+ WHERE e.from_id = ? AND e.kind = 'call' AND e.to_id IS NOT NULL
2195
+ `).all(symbolId);
2196
+ return rows.map(r => ({
2197
+ calleeId: toNum(r.calleeId), boundaryId: toNum(r.boundaryId),
2198
+ }));
2199
+ }
2200
+ catch {
2201
+ return [];
2202
+ }
2203
+ }
2204
+ /**
2205
+ * Return every files.id that's actually a phantom file backing an external
2206
+ * bundle layer. The indexer's prune pass preserves these so a local
2207
+ * re-index never drops external-imported rows.
2208
+ */
2209
+ listExternalPhantomFileIds() {
2210
+ try {
2211
+ const rows = this.db.prepare("SELECT id FROM files WHERE path LIKE '__external_bundle__/%'").all();
2212
+ return rows.map(r => toNum(r.id));
2213
+ }
2214
+ catch {
2215
+ return [];
2216
+ }
2217
+ }
2218
+ /** Insert (or replace) an external_bundles row for a given bundle path. */
2219
+ upsertExternalBundle(args) {
2220
+ this.assertWritable();
2221
+ if (!this.hasV10Tables)
2222
+ return 0;
2223
+ const existing = this.db.prepare('SELECT id FROM external_bundles WHERE bundle_path = ?').get(args.bundlePath);
2224
+ if (existing) {
2225
+ const id = toNum(existing.id);
2226
+ this.db.prepare(`
2227
+ UPDATE external_bundles
2228
+ SET external_project = ?, external_version = ?, external_hash = ?,
2229
+ schema_version = ?, imported_at = ?, routes_imported = ?,
2230
+ service_calls_imported = ?, service_links_imported = ?
2231
+ WHERE id = ?
2232
+ `).run(args.externalProject, args.externalVersion, args.externalHash, args.schemaVersion, Date.now(), args.routesImported, args.serviceCallsImported, args.serviceLinksImported, id);
2233
+ return id;
2234
+ }
2235
+ const r = this.db.prepare(`
2236
+ INSERT INTO external_bundles
2237
+ (source_kind, bundle_path, external_project, external_version, external_hash,
2238
+ schema_version, imported_at, routes_imported, service_calls_imported, service_links_imported)
2239
+ VALUES ('external-bundle', ?, ?, ?, ?, ?, ?, ?, ?, ?)
2240
+ `).run(args.bundlePath, args.externalProject, args.externalVersion, args.externalHash, args.schemaVersion, Date.now(), args.routesImported, args.serviceCallsImported, args.serviceLinksImported);
2241
+ return toNum(r.lastInsertRowid);
2242
+ }
2243
+ /**
2244
+ * Look up an existing external_bundles row by its bundle path. Returns the
2245
+ * id and the imported_at/external_hash for the existing layer when present.
2246
+ */
2247
+ findExternalBundleByPath(bundlePath) {
2248
+ if (!this.hasV10Tables)
2249
+ return null;
2250
+ try {
2251
+ const row = this.db.prepare(`
2252
+ SELECT id, bundle_path AS bundlePath, external_project AS externalProject,
2253
+ external_version AS externalVersion, external_hash AS externalHash
2254
+ FROM external_bundles WHERE bundle_path = ?
2255
+ `).get(bundlePath);
2256
+ if (!row)
2257
+ return null;
2258
+ return {
2259
+ id: toNum(row.id),
2260
+ bundlePath: toStr(row.bundlePath),
2261
+ externalProject: toNullStr(row.externalProject),
2262
+ externalVersion: toNullStr(row.externalVersion),
2263
+ externalHash: toNullStr(row.externalHash),
2264
+ };
2265
+ }
2266
+ catch {
2267
+ return null;
2268
+ }
2269
+ }
2270
+ /** List every external_bundles row (newest first). */
2271
+ listExternalBundles() {
2272
+ if (!this.hasV10Tables)
2273
+ return [];
2274
+ try {
2275
+ const rows = this.db.prepare(`
2276
+ SELECT id, source_kind AS sourceKind, bundle_path AS bundlePath,
2277
+ external_project AS externalProject,
2278
+ external_version AS externalVersion,
2279
+ external_hash AS externalHash,
2280
+ schema_version AS schemaVersion,
2281
+ imported_at AS importedAt,
2282
+ routes_imported AS routesImported,
2283
+ service_calls_imported AS serviceCallsImported,
2284
+ service_links_imported AS serviceLinksImported
2285
+ FROM external_bundles
2286
+ ORDER BY imported_at DESC
2287
+ `).all();
2288
+ return rows.map(r => ({
2289
+ id: toNum(r.id),
2290
+ sourceKind: toStr(r.sourceKind),
2291
+ bundlePath: toStr(r.bundlePath),
2292
+ externalProject: toNullStr(r.externalProject),
2293
+ externalVersion: toNullStr(r.externalVersion),
2294
+ externalHash: toNullStr(r.externalHash),
2295
+ schemaVersion: toNum(r.schemaVersion),
2296
+ importedAt: toNum(r.importedAt),
2297
+ routesImported: toNum(r.routesImported),
2298
+ serviceCallsImported: toNum(r.serviceCallsImported),
2299
+ serviceLinksImported: toNum(r.serviceLinksImported),
2300
+ }));
2301
+ }
2302
+ catch {
2303
+ return [];
2304
+ }
2305
+ }
2306
+ /**
2307
+ * Delete every row associated with a given external_bundles.id — its
2308
+ * routes/service_calls/service_links rows and the bundle row itself. Used
2309
+ * during re-import so a fresh import is fully replacing the previous
2310
+ * snapshot of that bundle.
2311
+ */
2312
+ clearExternalBundle(bundleId) {
2313
+ this.assertWritable();
2314
+ if (!this.hasV10Tables)
2315
+ return { routes: 0, serviceCalls: 0, serviceLinks: 0 };
2316
+ let routes = 0, serviceCalls = 0, serviceLinks = 0;
2317
+ this.db.exec('BEGIN');
2318
+ try {
2319
+ try {
2320
+ routes = toNum(this.db.prepare('DELETE FROM routes WHERE external_bundle_id = ?').run(bundleId).changes);
2321
+ }
2322
+ catch { /* */ }
2323
+ try {
2324
+ serviceCalls = toNum(this.db.prepare('DELETE FROM service_calls WHERE external_bundle_id = ?').run(bundleId).changes);
2325
+ }
2326
+ catch { /* */ }
2327
+ try {
2328
+ serviceLinks = toNum(this.db.prepare('DELETE FROM service_links WHERE external_bundle_id = ?').run(bundleId).changes);
2329
+ }
2330
+ catch { /* */ }
2331
+ // Drop the phantom file row that owned this layer's external routes so a
2332
+ // forced re-import (which mints a new bundle id + phantom path) does not
2333
+ // leak orphaned `__external_bundle__/...` rows alongside sibling layers.
2334
+ try {
2335
+ this.db.prepare("DELETE FROM files WHERE hash = ? AND path LIKE '__external_bundle__/%'").run(`external:${bundleId}`);
2336
+ }
2337
+ catch { /* */ }
2338
+ this.db.prepare('DELETE FROM external_bundles WHERE id = ?').run(bundleId);
2339
+ this.db.exec('COMMIT');
2340
+ }
2341
+ catch (err) {
2342
+ this.db.exec('ROLLBACK');
2343
+ throw err;
2344
+ }
2345
+ return { routes, serviceCalls, serviceLinks };
2346
+ }
2347
+ /**
2348
+ * Insert a route from an external bundle. file_id is intentionally NULL —
2349
+ * external routes do not belong to any local file. The Store schema does
2350
+ * not allow NULL on routes.file_id by default; v10 keeps file_id NOT NULL,
2351
+ * so we have to ensure an external "phantom" file row exists per bundle to
2352
+ * own the routes. The route stays linked to the external_bundle_id so we
2353
+ * can wipe them as a layer.
2354
+ */
2355
+ insertExternalRoute(args) {
2356
+ this.assertWritable();
2357
+ if (!this.hasV10Tables)
2358
+ return 0;
2359
+ const r = this.db.prepare(`
2360
+ INSERT INTO routes
2361
+ (file_id, method, path, framework, handler_name, line,
2362
+ protocol, operation, topic, queue, exchange, service, broker, metadata_json,
2363
+ external_bundle_id)
2364
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2365
+ `).run(args.externalFileId, args.method, args.path, args.framework, args.handlerName, args.line, args.protocol ?? 'http', args.operation ?? null, args.topic ?? null, args.queue ?? null, args.exchange ?? null, args.service ?? null, args.broker ?? null, args.metadataJson ?? null, args.bundleId);
2366
+ return toNum(r.lastInsertRowid);
2367
+ }
2368
+ /**
2369
+ * Create (or reuse) an "external" phantom file row that owns external
2370
+ * bundle rows. Each external_bundles.id gets its own external-phantom file
2371
+ * so deleting a layer doesn't disturb sibling layers. The phantom file
2372
+ * carries role='vendor' so it stays out of project-first defaults.
2373
+ */
2374
+ ensureExternalFile(bundleId, externalProject) {
2375
+ this.assertWritable();
2376
+ const phantomPath = `__external_bundle__/${externalProject}/${bundleId}`;
2377
+ const existing = this.db.prepare('SELECT id FROM files WHERE path = ?')
2378
+ .get(phantomPath);
2379
+ if (existing)
2380
+ return toNum(existing.id);
2381
+ const r = this.stmtUpsertFile.run(phantomPath, phantomPath, 'external', `external:${bundleId}`, 0, Date.now(), 'vendor', 1, 0);
2382
+ return toNum(r.lastInsertRowid);
2383
+ }
2384
+ /** Count of routes that came from an external bundle. */
2385
+ countExternalRoutes() {
2386
+ if (!this.hasV10Tables)
2387
+ return 0;
2388
+ try {
2389
+ const row = this.db.prepare('SELECT COUNT(*) AS c FROM routes WHERE external_bundle_id IS NOT NULL').get();
2390
+ return toNum(row.c);
2391
+ }
2392
+ catch {
2393
+ return 0;
2394
+ }
2395
+ }
2396
+ /**
2397
+ * List routes filtered to external bundles only. Useful for verifying that
2398
+ * an external import landed and for the seer_external_bundles MCP tool.
2399
+ */
2400
+ listExternalRoutes(options = {}) {
2401
+ if (!this.hasV10Tables)
2402
+ return [];
2403
+ const where = ['r.external_bundle_id IS NOT NULL'];
2404
+ const args = [];
2405
+ if (options.bundleId != null) {
2406
+ where.push('r.external_bundle_id = ?');
2407
+ args.push(options.bundleId);
2408
+ }
2409
+ if (options.method) {
2410
+ where.push('r.method = ?');
2411
+ args.push(options.method.toUpperCase());
2412
+ }
2413
+ if (options.pathSubstr) {
2414
+ where.push('r.path LIKE ?');
2415
+ args.push(`%${options.pathSubstr}%`);
2416
+ }
2417
+ if (options.protocol) {
2418
+ where.push('r.protocol = ?');
2419
+ args.push(options.protocol);
2420
+ }
2421
+ const limit = options.limit ?? 200;
2422
+ args.push(limit);
2423
+ try {
2424
+ const rows = this.db.prepare(`
2425
+ SELECT r.id, r.method, r.path, r.framework, r.handler_name AS handlerName,
2426
+ r.line, r.protocol, r.operation, r.topic, r.queue, r.service,
2427
+ r.external_bundle_id AS externalBundleId,
2428
+ eb.external_project AS externalProject
2429
+ FROM routes r
2430
+ JOIN external_bundles eb ON eb.id = r.external_bundle_id
2431
+ WHERE ${where.join(' AND ')}
2432
+ ORDER BY r.path, r.method
2433
+ LIMIT ?
2434
+ `).all(...args);
2435
+ return rows.map(r => ({
2436
+ id: toNum(r.id),
2437
+ method: toStr(r.method),
2438
+ path: toStr(r.path),
2439
+ framework: toStr(r.framework),
2440
+ handlerName: toNullStr(r.handlerName),
2441
+ line: toNum(r.line),
2442
+ protocol: toNullStr(r.protocol),
2443
+ operation: toNullStr(r.operation),
2444
+ topic: toNullStr(r.topic),
2445
+ queue: toNullStr(r.queue),
2446
+ service: toNullStr(r.service),
2447
+ externalBundleId: toNum(r.externalBundleId),
2448
+ externalProject: toNullStr(r.externalProject),
2449
+ }));
2450
+ }
2451
+ catch {
2452
+ return [];
2453
+ }
2454
+ }
2455
+ // ── External dependencies ───────────────────────────────────────────────────
2456
+ listExternalDeps(options = {}) {
2457
+ if (!this.hasV4Tables)
2458
+ return [];
2459
+ const where = [];
2460
+ const args = [];
2461
+ if (options.ecosystem) {
2462
+ where.push('ecosystem = ?');
2463
+ args.push(options.ecosystem);
2464
+ }
2465
+ if (options.nameSubstr) {
2466
+ where.push('name LIKE ?');
2467
+ args.push(`%${options.nameSubstr}%`);
2468
+ }
2469
+ const limit = options.limit ?? 500;
2470
+ args.push(limit);
2471
+ const sql = `
2472
+ SELECT id, ecosystem, name, version_range AS versionRange,
2473
+ manifest_path AS manifestPath, is_dev AS isDev
2474
+ FROM external_dependencies
2475
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
2476
+ ORDER BY ecosystem, name
2477
+ LIMIT ?
2478
+ `;
2479
+ const rows = this.db.prepare(sql).all(...args);
2480
+ return rows.map(r => ({
2481
+ id: toNum(r.id),
2482
+ ecosystem: toStr(r.ecosystem),
2483
+ name: toStr(r.name),
2484
+ versionRange: toNullStr(r.versionRange),
2485
+ manifestPath: toStr(r.manifestPath),
2486
+ isDev: toNum(r.isDev),
2487
+ }));
2488
+ }
2489
+ countExternalDeps() {
2490
+ if (!this.hasV4Tables)
2491
+ return 0;
2492
+ const row = this.db.prepare('SELECT COUNT(*) AS c FROM external_dependencies').get();
2493
+ return toNum(row.c);
2494
+ }
2495
+ // ── Config keys ─────────────────────────────────────────────────────────────
2496
+ listConfigKeys(options = {}) {
2497
+ if (!this.hasV4Tables)
2498
+ return [];
2499
+ const where = [];
2500
+ const args = [];
2501
+ if (options.key) {
2502
+ where.push('c.key LIKE ?');
2503
+ args.push(`%${options.key}%`);
2504
+ }
2505
+ if (options.source) {
2506
+ where.push('c.source = ?');
2507
+ args.push(options.source);
2508
+ }
2509
+ const limit = options.limit ?? 200;
2510
+ args.push(limit);
2511
+ const sql = `
2512
+ SELECT c.id, c.key, c.source, f.path AS filePath,
2513
+ c.symbol_id AS symbolId,
2514
+ s.qualified_name AS symbolName,
2515
+ c.line
2516
+ FROM config_keys c
2517
+ JOIN files f ON f.id = c.file_id
2518
+ LEFT JOIN symbols s ON s.id = c.symbol_id
2519
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
2520
+ ORDER BY c.key, f.path
2521
+ LIMIT ?
2522
+ `;
2523
+ const rows = this.db.prepare(sql).all(...args);
2524
+ return rows.map(r => ({
2525
+ id: toNum(r.id),
2526
+ key: toStr(r.key),
2527
+ source: toStr(r.source),
2528
+ filePath: toStr(r.filePath),
2529
+ symbolId: toNullNum(r.symbolId),
2530
+ symbolName: toNullStr(r.symbolName),
2531
+ line: toNum(r.line),
2532
+ }));
2533
+ }
2534
+ countConfigKeys() {
2535
+ if (!this.hasV4Tables)
2536
+ return 0;
2537
+ const row = this.db.prepare('SELECT COUNT(*) AS c FROM config_keys').get();
2538
+ return toNum(row.c);
2539
+ }
2540
+ // ── File churn ──────────────────────────────────────────────────────────────
2541
+ upsertFileChurn(fileId, commitCount, lastCommitSha, lastCommitAt, topAuthor, secondAuthor) {
2542
+ this.db.prepare(`
2543
+ INSERT INTO file_churn (file_id, commit_count, last_commit_sha, last_commit_at, top_author, second_author, collected_at)
2544
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2545
+ ON CONFLICT(file_id) DO UPDATE SET
2546
+ commit_count = excluded.commit_count,
2547
+ last_commit_sha = excluded.last_commit_sha,
2548
+ last_commit_at = excluded.last_commit_at,
2549
+ top_author = excluded.top_author,
2550
+ second_author = excluded.second_author,
2551
+ collected_at = excluded.collected_at
2552
+ `).run(fileId, commitCount, lastCommitSha, lastCommitAt, topAuthor, secondAuthor, Date.now());
2553
+ }
2554
+ getFileChurn(filePath) {
2555
+ if (!this.hasV4Tables)
2556
+ return null;
2557
+ const row = this.db.prepare(`
2558
+ SELECT c.file_id AS fileId, f.path AS filePath,
2559
+ c.commit_count AS commitCount,
2560
+ c.last_commit_sha AS lastCommitSha,
2561
+ c.last_commit_at AS lastCommitAt,
2562
+ c.top_author AS topAuthor,
2563
+ c.second_author AS secondAuthor
2564
+ FROM file_churn c JOIN files f ON f.id = c.file_id
2565
+ WHERE f.path = ? OR f.rel_path = ?
2566
+ `).get(filePath, filePath);
2567
+ if (!row)
2568
+ return null;
2569
+ return {
2570
+ fileId: toNum(row.fileId),
2571
+ filePath: toStr(row.filePath),
2572
+ commitCount: toNum(row.commitCount),
2573
+ lastCommitSha: toNullStr(row.lastCommitSha),
2574
+ lastCommitAt: toNullNum(row.lastCommitAt),
2575
+ topAuthor: toNullStr(row.topAuthor),
2576
+ secondAuthor: toNullStr(row.secondAuthor),
2577
+ };
2578
+ }
2579
+ topChurnedFiles(limit = 20) {
2580
+ if (!this.hasV4Tables)
2581
+ return [];
2582
+ const rows = this.db.prepare(`
2583
+ SELECT c.file_id AS fileId, f.path AS filePath,
2584
+ c.commit_count AS commitCount,
2585
+ c.last_commit_sha AS lastCommitSha,
2586
+ c.last_commit_at AS lastCommitAt,
2587
+ c.top_author AS topAuthor,
2588
+ c.second_author AS secondAuthor
2589
+ FROM file_churn c JOIN files f ON f.id = c.file_id
2590
+ ORDER BY c.commit_count DESC
2591
+ LIMIT ?
2592
+ `).all(limit);
2593
+ return rows.map(r => ({
2594
+ fileId: toNum(r.fileId),
2595
+ filePath: toStr(r.filePath),
2596
+ commitCount: toNum(r.commitCount),
2597
+ lastCommitSha: toNullStr(r.lastCommitSha),
2598
+ lastCommitAt: toNullNum(r.lastCommitAt),
2599
+ topAuthor: toNullStr(r.topAuthor),
2600
+ secondAuthor: toNullStr(r.secondAuthor),
2601
+ }));
2602
+ }
2603
+ // ── Symbol history ──────────────────────────────────────────────────────────
2604
+ insertSymbolHistory(symbolId, symbolKey, commitSha, authorName, authorEmail, committedAt, message, linesAdded, linesRemoved, prNumber, prUrl, matchStrategy, confidence) {
2605
+ this.db.prepare(`
2606
+ INSERT OR IGNORE INTO symbol_history
2607
+ (symbol_id, symbol_key, commit_sha, author_name, author_email, committed_at, message,
2608
+ lines_added, lines_removed, pr_number, pr_url, match_strategy, confidence)
2609
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2610
+ `).run(symbolId, symbolKey, commitSha, authorName, authorEmail, committedAt, message, linesAdded, linesRemoved, prNumber, prUrl, matchStrategy, confidence);
2611
+ }
2612
+ getSymbolHistory(symbolId, options = {}) {
2613
+ if (!this.hasV4Tables)
2614
+ return [];
2615
+ const limit = Math.max(1, options.limit ?? 50);
2616
+ const since = options.since;
2617
+ const where = since != null ? 'AND committed_at >= ?' : '';
2618
+ const args = [symbolId];
2619
+ if (since != null)
2620
+ args.push(since);
2621
+ args.push(limit);
2622
+ const rows = this.db.prepare(`
2623
+ SELECT id, symbol_id AS symbolId, symbol_key AS symbolKey, commit_sha AS commitSha,
2624
+ author_name AS authorName, author_email AS authorEmail,
2625
+ committed_at AS committedAt, message,
2626
+ lines_added AS linesAdded, lines_removed AS linesRemoved,
2627
+ pr_number AS prNumber, pr_url AS prUrl,
2628
+ match_strategy AS matchStrategy, confidence
2629
+ FROM symbol_history
2630
+ WHERE symbol_id = ? ${where}
2631
+ ORDER BY committed_at DESC
2632
+ LIMIT ?
2633
+ `).all(...args);
2634
+ return rows.map(r => ({
2635
+ id: toNum(r.id),
2636
+ symbolId: toNum(r.symbolId),
2637
+ symbolKey: toStr(r.symbolKey),
2638
+ commitSha: toStr(r.commitSha),
2639
+ authorName: toNullStr(r.authorName),
2640
+ authorEmail: toNullStr(r.authorEmail),
2641
+ committedAt: toNum(r.committedAt),
2642
+ message: toNullStr(r.message),
2643
+ linesAdded: toNum(r.linesAdded),
2644
+ linesRemoved: toNum(r.linesRemoved),
2645
+ prNumber: toNullNum(r.prNumber),
2646
+ prUrl: toNullStr(r.prUrl),
2647
+ matchStrategy: toStr(r.matchStrategy),
2648
+ confidence: Number(r.confidence),
2649
+ }));
2650
+ }
2651
+ /** Total history count for a symbol — for "showing N of M commits" headers. */
2652
+ countSymbolHistory(symbolId) {
2653
+ if (!this.hasV4Tables)
2654
+ return 0;
2655
+ const row = this.db.prepare('SELECT COUNT(*) AS c FROM symbol_history WHERE symbol_id = ?').get(symbolId);
2656
+ return toNum(row.c);
2657
+ }
2658
+ getGitIndexState() {
2659
+ if (!this.hasV4Tables)
2660
+ return null;
2661
+ const row = this.db.prepare(`SELECT repo_root AS repoRoot, last_head_sha AS lastHeadSha,
2662
+ last_processed_at AS lastProcessedAt, remote_url AS remoteUrl,
2663
+ algorithm_version AS algorithmVersion,
2664
+ last_history_head_sha AS lastHistoryHeadSha,
2665
+ last_history_at AS lastHistoryAt
2666
+ FROM git_index_state WHERE id = 1`).get();
2667
+ if (!row)
2668
+ return null;
2669
+ return {
2670
+ repoRoot: toStr(row.repoRoot),
2671
+ lastHeadSha: toNullStr(row.lastHeadSha),
2672
+ lastProcessedAt: toNum(row.lastProcessedAt),
2673
+ remoteUrl: toNullStr(row.remoteUrl),
2674
+ algorithmVersion: toNum(row.algorithmVersion),
2675
+ lastHistoryHeadSha: toNullStr(row.lastHistoryHeadSha),
2676
+ lastHistoryAt: row.lastHistoryAt == null ? null : toNum(row.lastHistoryAt),
2677
+ };
2678
+ }
2679
+ /**
2680
+ * Generic "the indexer has seen this HEAD" stamp — used by churn and any
2681
+ * other read-only git pass. Does NOT touch the history-specific marker.
2682
+ * symbol-history has its own setHistoryHeadSha() so the two passes can't
2683
+ * mask each other.
2684
+ */
2685
+ setGitIndexState(repoRoot, lastHeadSha, remoteUrl, algorithmVersion = 1) {
2686
+ this.db.prepare(`
2687
+ INSERT INTO git_index_state (id, repo_root, last_head_sha, last_processed_at, remote_url, algorithm_version)
2688
+ VALUES (1, ?, ?, ?, ?, ?)
2689
+ ON CONFLICT(id) DO UPDATE SET
2690
+ repo_root = excluded.repo_root,
2691
+ last_head_sha = excluded.last_head_sha,
2692
+ last_processed_at = excluded.last_processed_at,
2693
+ remote_url = excluded.remote_url,
2694
+ algorithm_version = excluded.algorithm_version
2695
+ `).run(repoRoot, lastHeadSha, Date.now(), remoteUrl, algorithmVersion);
2696
+ }
2697
+ /**
2698
+ * Stamp the HEAD that symbol-history was last built against. Independent of
2699
+ * setGitIndexState() so running file-level churn never makes a subsequent
2700
+ * buildSymbolHistory() skip.
2701
+ */
2702
+ setHistoryHeadSha(repoRoot, lastHistoryHeadSha, remoteUrl) {
2703
+ // Upsert: insert a fresh row if churn hasn't run yet; otherwise just
2704
+ // update the history columns. repo_root + remote_url are kept in sync
2705
+ // either way so the row stays self-describing.
2706
+ this.db.prepare(`
2707
+ INSERT INTO git_index_state
2708
+ (id, repo_root, last_processed_at, remote_url, algorithm_version,
2709
+ last_history_head_sha, last_history_at)
2710
+ VALUES (1, ?, ?, ?, 1, ?, ?)
2711
+ ON CONFLICT(id) DO UPDATE SET
2712
+ repo_root = excluded.repo_root,
2713
+ remote_url = COALESCE(excluded.remote_url, git_index_state.remote_url),
2714
+ last_history_head_sha = excluded.last_history_head_sha,
2715
+ last_history_at = excluded.last_history_at
2716
+ `).run(repoRoot, Date.now(), remoteUrl, lastHistoryHeadSha, Date.now());
2717
+ }
2718
+ /** All symbols matching a symbol_key — used by `seer_history` to find the
2719
+ * current id for a key that came from the indexed graph. */
2720
+ findSymbolsByKey(symbolKey) {
2721
+ const rows = this.db.prepare(`
2722
+ SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
2723
+ FROM symbols s JOIN files f ON f.id = s.file_id
2724
+ WHERE s.symbol_key = ?
2725
+ ORDER BY s.pagerank DESC
2726
+ `).all(symbolKey);
2727
+ return rows.map(toSymbolRow);
2728
+ }
2729
+ /** Iterate over (id, file_id, line_start, line_end, symbol_key) — used by
2730
+ * the symbol-history indexer to map historical line ranges to current ids. */
2731
+ listSymbolsForHistoryIndex() {
2732
+ const rows = this.db.prepare(`
2733
+ SELECT s.id, s.file_id AS fileId, f.path AS filePath, f.rel_path AS relPath,
2734
+ s.line_start AS lineStart, s.line_end AS lineEnd, s.symbol_key AS symbolKey
2735
+ FROM symbols s JOIN files f ON f.id = s.file_id
2736
+ WHERE s.symbol_key IS NOT NULL
2737
+ AND s.kind IN ('function','method','constructor','class')
2738
+ `).all();
2739
+ return rows.map(r => ({
2740
+ id: toNum(r.id), fileId: toNum(r.fileId),
2741
+ filePath: toStr(r.filePath), relPath: toStr(r.relPath),
2742
+ lineStart: toNum(r.lineStart), lineEnd: toNum(r.lineEnd),
2743
+ symbolKey: toStr(r.symbolKey),
2744
+ }));
2745
+ }
2746
+ // ── PageRank helpers ────────────────────────────────────────────────────────
2747
+ getAllEdges() {
2748
+ const rows = this.db.prepare(`
2749
+ SELECT e.from_id AS \`from\`, e.to_id AS \`to\`
2750
+ FROM edges e
2751
+ JOIN symbols sf ON sf.id = e.from_id AND sf.is_rankable = 1
2752
+ JOIN symbols st ON st.id = e.to_id AND st.is_rankable = 1
2753
+ WHERE e.to_id IS NOT NULL
2754
+ AND e.kind = 'call'
2755
+ `).all();
2756
+ return rows.map(r => ({ from: toNum(r.from), to: toNum(r.to) }));
2757
+ }
2758
+ getAllSymbolIds() {
2759
+ const rows = this.db.prepare('SELECT id FROM symbols WHERE is_rankable = 1').all();
2760
+ return rows.map(r => toNum(r.id));
2761
+ }
2762
+ updatePageRanks(ranks) {
2763
+ const stmt = this.db.prepare('UPDATE symbols SET pagerank = ? WHERE id = ?');
2764
+ this.db.exec('BEGIN');
2765
+ try {
2766
+ this.db.prepare('UPDATE symbols SET pagerank = 0 WHERE is_rankable = 0 AND pagerank != 0').run();
2767
+ for (const [id, rank] of ranks) {
2768
+ stmt.run(rank, id);
2769
+ }
2770
+ this.db.exec('COMMIT');
2771
+ }
2772
+ catch (err) {
2773
+ this.db.exec('ROLLBACK');
2774
+ throw err;
2775
+ }
2776
+ }
2777
+ // ── Graph traversal ─────────────────────────────────────────────────────────
2778
+ /**
2779
+ * Bounded breadth-first search over the call graph. Returns one shortest
2780
+ * path from `fromId` to `toId` (by edge count), or null if none found.
2781
+ * The search expands at most `maxDepth` hops and at most `maxNodes` nodes
2782
+ * visited overall — without those caps a cycle in the graph would explode.
2783
+ */
2784
+ tracePath(fromId, toId, maxDepth = 6, maxNodes = 20_000) {
2785
+ if (fromId === toId) {
2786
+ const row = this.getSymbolById(fromId);
2787
+ return row ? [{ id: row.id, name: row.name, qualifiedName: row.qualifiedName, kind: row.kind, filePath: row.filePath }] : null;
2788
+ }
2789
+ const adjStmt = this.db.prepare("SELECT DISTINCT to_id FROM edges WHERE from_id = ? AND to_id IS NOT NULL AND kind = 'call'");
2790
+ const parent = new Map();
2791
+ parent.set(fromId, -1);
2792
+ const queue = [{ id: fromId, depth: 0 }];
2793
+ let visited = 0;
2794
+ while (queue.length > 0) {
2795
+ const { id, depth } = queue.shift();
2796
+ visited++;
2797
+ if (visited > maxNodes)
2798
+ return null;
2799
+ if (depth >= maxDepth)
2800
+ continue;
2801
+ const rows = adjStmt.all(id);
2802
+ for (const r of rows) {
2803
+ const next = toNum(r.to_id);
2804
+ if (parent.has(next))
2805
+ continue;
2806
+ parent.set(next, id);
2807
+ if (next === toId) {
2808
+ // Reconstruct
2809
+ const path = [];
2810
+ let cur = next;
2811
+ while (cur !== -1) {
2812
+ path.push(cur);
2813
+ cur = parent.get(cur);
2814
+ }
2815
+ path.reverse();
2816
+ return path.map(pid => {
2817
+ const s = this.getSymbolById(pid);
2818
+ return s ? { id: s.id, name: s.name, qualifiedName: s.qualifiedName, kind: s.kind, filePath: s.filePath }
2819
+ : { id: pid, name: '', qualifiedName: null, kind: '', filePath: '' };
2820
+ });
2821
+ }
2822
+ queue.push({ id: next, depth: depth + 1 });
2823
+ }
2824
+ }
2825
+ return null;
2826
+ }
2827
+ /** Reverse BFS from a symbol — for "everything that transitively calls X". */
2828
+ reverseReachable(toId, maxDepth = 4, maxNodes = 20_000) {
2829
+ const stmt = this.db.prepare("SELECT DISTINCT from_id FROM edges WHERE to_id = ? AND kind = 'call'");
2830
+ const seen = new Set([toId]);
2831
+ const queue = [{ id: toId, depth: 0 }];
2832
+ while (queue.length > 0) {
2833
+ const { id, depth } = queue.shift();
2834
+ if (seen.size > maxNodes)
2835
+ break;
2836
+ if (depth >= maxDepth)
2837
+ continue;
2838
+ const rows = stmt.all(id);
2839
+ for (const r of rows) {
2840
+ const next = toNum(r.from_id);
2841
+ if (seen.has(next))
2842
+ continue;
2843
+ seen.add(next);
2844
+ queue.push({ id: next, depth: depth + 1 });
2845
+ }
2846
+ }
2847
+ seen.delete(toId);
2848
+ return Array.from(seen);
2849
+ }
2850
+ /**
2851
+ * Bounded reverse-reachable callers WITH depth, for risk/context callers.
2852
+ * Same termination semantics as reverseReachable() but returns the depth
2853
+ * at which each id was first discovered (1-indexed; direct callers = 1).
2854
+ */
2855
+ reverseReachableWithDepth(toId, maxDepth = 4, maxNodes = 20_000) {
2856
+ const stmt = this.db.prepare("SELECT DISTINCT from_id FROM edges WHERE to_id = ? AND kind = 'call'");
2857
+ const seen = new Map([[toId, 0]]);
2858
+ const queue = [{ id: toId, depth: 0 }];
2859
+ while (queue.length > 0) {
2860
+ const { id, depth } = queue.shift();
2861
+ if (seen.size > maxNodes)
2862
+ break;
2863
+ if (depth >= maxDepth)
2864
+ continue;
2865
+ const rows = stmt.all(id);
2866
+ for (const r of rows) {
2867
+ const next = toNum(r.from_id);
2868
+ if (seen.has(next))
2869
+ continue;
2870
+ seen.set(next, depth + 1);
2871
+ queue.push({ id: next, depth: depth + 1 });
2872
+ }
2873
+ }
2874
+ seen.delete(toId);
2875
+ return Array.from(seen.entries()).map(([id, depth]) => ({ id, depth }));
2876
+ }
2877
+ /**
2878
+ * Bounded forward-reachable callees with depth — for callee blast-radius
2879
+ * questions and behavioral indirect-coverage. Mirror of
2880
+ * reverseReachableWithDepth().
2881
+ */
2882
+ forwardReachableWithDepth(fromId, maxDepth = 4, maxNodes = 20_000) {
2883
+ const stmt = this.db.prepare("SELECT DISTINCT to_id FROM edges WHERE from_id = ? AND to_id IS NOT NULL AND kind = 'call'");
2884
+ const seen = new Map([[fromId, 0]]);
2885
+ const queue = [{ id: fromId, depth: 0 }];
2886
+ while (queue.length > 0) {
2887
+ const { id, depth } = queue.shift();
2888
+ if (seen.size > maxNodes)
2889
+ break;
2890
+ if (depth >= maxDepth)
2891
+ continue;
2892
+ const rows = stmt.all(id);
2893
+ for (const r of rows) {
2894
+ const next = toNum(r.to_id);
2895
+ if (seen.has(next))
2896
+ continue;
2897
+ seen.set(next, depth + 1);
2898
+ queue.push({ id: next, depth: depth + 1 });
2899
+ }
2900
+ }
2901
+ seen.delete(fromId);
2902
+ return Array.from(seen.entries()).map(([id, depth]) => ({ id, depth }));
2903
+ }
2904
+ /**
2905
+ * Bounded BFS over the file-import graph. Used by
2906
+ * seer_trace_file_dependencies — returns each reachable file with the BFS
2907
+ * depth at which we first saw it.
2908
+ */
2909
+ fileImportClosure(fileId, maxDepth = 4, maxNodes = 5_000) {
2910
+ const stmt = this.db.prepare('SELECT DISTINCT resolved_file_id FROM file_imports WHERE from_file_id = ? AND resolved_file_id IS NOT NULL');
2911
+ const seen = new Map([[fileId, 0]]);
2912
+ const queue = [{ id: fileId, depth: 0 }];
2913
+ while (queue.length > 0) {
2914
+ const { id, depth } = queue.shift();
2915
+ if (seen.size > maxNodes)
2916
+ break;
2917
+ if (depth >= maxDepth)
2918
+ continue;
2919
+ const rows = stmt.all(id);
2920
+ for (const r of rows) {
2921
+ const next = toNum(r.resolved_file_id);
2922
+ if (seen.has(next))
2923
+ continue;
2924
+ seen.set(next, depth + 1);
2925
+ queue.push({ id: next, depth: depth + 1 });
2926
+ }
2927
+ }
2928
+ seen.delete(fileId);
2929
+ if (seen.size === 0)
2930
+ return [];
2931
+ const ids = Array.from(seen.keys());
2932
+ const placeholders = ids.map(() => '?').join(',');
2933
+ const rows = this.db.prepare(`SELECT id, rel_path AS relPath, language FROM files WHERE id IN (${placeholders})`).all(...ids);
2934
+ const meta = new Map(rows.map(r => [
2935
+ toNum(r.id),
2936
+ { relPath: toStr(r.relPath), language: toStr(r.language) },
2937
+ ]));
2938
+ return ids.map(id => {
2939
+ const m = meta.get(id);
2940
+ return {
2941
+ id,
2942
+ depth: seen.get(id),
2943
+ relPath: m?.relPath ?? '',
2944
+ language: m?.language ?? '',
2945
+ };
2946
+ });
2947
+ }
2948
+ // ── Track-E: file/module aggregate graph helpers ────────────────────────────
2949
+ /**
2950
+ * All cross-file call edges as (fromFile, toFile, weight) triples.
2951
+ * Used by the Louvain clusterer; only resolved 'call' edges count.
2952
+ */
2953
+ fileCallEdgeWeights() {
2954
+ const rows = this.db.prepare(`
2955
+ SELECT sf.file_id AS fromFile, st.file_id AS toFile, COUNT(*) AS w
2956
+ FROM edges e
2957
+ JOIN symbols sf ON sf.id = e.from_id
2958
+ JOIN symbols st ON st.id = e.to_id
2959
+ WHERE e.kind = 'call' AND e.to_id IS NOT NULL
2960
+ AND sf.file_id <> st.file_id
2961
+ GROUP BY sf.file_id, st.file_id
2962
+ `).all();
2963
+ return rows.map(r => ({
2964
+ from: toNum(r.fromFile),
2965
+ to: toNum(r.toFile),
2966
+ weight: toNum(r.w),
2967
+ }));
2968
+ }
2969
+ /** Resolved cross-file import edges as (fromFile, toFile, weight). */
2970
+ fileImportEdgeWeights() {
2971
+ const rows = this.db.prepare(`
2972
+ SELECT from_file_id AS fromFile, resolved_file_id AS toFile, COUNT(*) AS w
2973
+ FROM file_imports
2974
+ WHERE resolved_file_id IS NOT NULL
2975
+ AND from_file_id <> resolved_file_id
2976
+ GROUP BY from_file_id, resolved_file_id
2977
+ `).all();
2978
+ return rows.map(r => ({
2979
+ from: toNum(r.fromFile),
2980
+ to: toNum(r.toFile),
2981
+ weight: toNum(r.w),
2982
+ }));
2983
+ }
2984
+ /** Synthesized test → production edges, file-aggregated. */
2985
+ fileTestEdgeWeights() {
2986
+ const rows = this.db.prepare(`
2987
+ SELECT sf.file_id AS fromFile, st.file_id AS toFile, COUNT(*) AS w
2988
+ FROM edges e
2989
+ JOIN symbols sf ON sf.id = e.from_id
2990
+ JOIN symbols st ON st.id = e.to_id
2991
+ WHERE e.kind = 'tests' AND e.to_id IS NOT NULL
2992
+ AND sf.file_id <> st.file_id
2993
+ GROUP BY sf.file_id, st.file_id
2994
+ `).all();
2995
+ return rows.map(r => ({
2996
+ from: toNum(r.fromFile),
2997
+ to: toNum(r.toFile),
2998
+ weight: toNum(r.w),
2999
+ }));
3000
+ }
3001
+ /**
3002
+ * v8 Track-G — service-link file-aggregated edges. Each link contributes one
3003
+ * cross-file edge from the call-site file (service_calls.file_id) to the
3004
+ * handler-symbol's file. Used by the module clusterer to surface
3005
+ * client→handler dependencies as architecturally important.
3006
+ */
3007
+ fileServiceLinkEdgeWeights() {
3008
+ try {
3009
+ const rows = this.db.prepare(`
3010
+ SELECT sc.file_id AS fromFile, hs.file_id AS toFile, COUNT(*) AS w
3011
+ FROM service_links sl
3012
+ JOIN service_calls sc ON sc.id = sl.call_id
3013
+ LEFT JOIN symbols hs ON hs.id = sl.handler_symbol_id
3014
+ WHERE hs.file_id IS NOT NULL
3015
+ AND sc.file_id <> hs.file_id
3016
+ GROUP BY sc.file_id, hs.file_id
3017
+ `).all();
3018
+ return rows.map(r => ({
3019
+ from: toNum(r.fromFile),
3020
+ to: toNum(r.toFile),
3021
+ weight: toNum(r.w),
3022
+ }));
3023
+ }
3024
+ catch {
3025
+ return [];
3026
+ }
3027
+ }
3028
+ /** All file ids + their language + rel path — feeds the clusterer. */
3029
+ listFileSummaries() {
3030
+ const rows = this.db.prepare('SELECT id, rel_path AS relPath, language, role FROM files').all();
3031
+ return rows.map(r => ({
3032
+ id: toNum(r.id), relPath: toStr(r.relPath),
3033
+ language: toStr(r.language), role: toStr(r.role),
3034
+ }));
3035
+ }
3036
+ // ── Track-E: modules persistence ────────────────────────────────────────────
3037
+ /**
3038
+ * Replace the modules / module_members / module_edges tables with the
3039
+ * provided clustering. Atomic — wrapped in a single transaction so a
3040
+ * partial write can't leave inconsistent membership.
3041
+ */
3042
+ replaceModules(modules, edges, algorithm = 'louvain') {
3043
+ if (!this.hasModuleTables)
3044
+ return;
3045
+ this.db.exec('BEGIN');
3046
+ try {
3047
+ this.db.exec('DELETE FROM module_edges');
3048
+ this.db.exec('DELETE FROM module_members');
3049
+ this.db.exec('DELETE FROM modules');
3050
+ const insModule = this.db.prepare(`
3051
+ INSERT INTO modules (label, size_files, size_symbols, primary_language, cohesion, centrality, computed_at, algorithm)
3052
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3053
+ `);
3054
+ const insMember = this.db.prepare('INSERT INTO module_members (file_id, module_id) VALUES (?, ?)');
3055
+ const insEdge = this.db.prepare('INSERT OR REPLACE INTO module_edges (from_module_id, to_module_id, kind, weight) VALUES (?, ?, ?, ?)');
3056
+ const now = Date.now();
3057
+ const indexToId = [];
3058
+ for (const m of modules) {
3059
+ const res = insModule.run(m.label, m.sizeFiles, m.sizeSymbols, m.primaryLanguage, m.cohesion, m.centrality, now, algorithm);
3060
+ const id = toNum(res.lastInsertRowid);
3061
+ indexToId.push(id);
3062
+ for (const fid of m.fileIds)
3063
+ insMember.run(fid, id);
3064
+ }
3065
+ for (const e of edges) {
3066
+ const f = indexToId[e.fromIndex];
3067
+ const t = indexToId[e.toIndex];
3068
+ if (f == null || t == null)
3069
+ continue;
3070
+ insEdge.run(f, t, e.kind, e.weight);
3071
+ }
3072
+ this.db.exec('COMMIT');
3073
+ }
3074
+ catch (err) {
3075
+ this.db.exec('ROLLBACK');
3076
+ throw err;
3077
+ }
3078
+ }
3079
+ hasModulesData() {
3080
+ if (!this.hasModuleTables)
3081
+ return false;
3082
+ try {
3083
+ const row = this.db.prepare('SELECT COUNT(*) AS c FROM modules').get();
3084
+ return toNum(row.c) > 0;
3085
+ }
3086
+ catch {
3087
+ return false;
3088
+ }
3089
+ }
3090
+ countModules() {
3091
+ if (!this.hasModuleTables)
3092
+ return 0;
3093
+ try {
3094
+ return toNum(this.db.prepare('SELECT COUNT(*) AS c FROM modules').get().c);
3095
+ }
3096
+ catch {
3097
+ return 0;
3098
+ }
3099
+ }
3100
+ listModules(options = {}) {
3101
+ if (!this.hasModuleTables)
3102
+ return [];
3103
+ const limit = options.limit ?? 100;
3104
+ const sortBy = options.sortBy ?? 'centrality';
3105
+ const order = sortBy === 'label' ? 'label ASC'
3106
+ : sortBy === 'size' ? 'size_files DESC, size_symbols DESC'
3107
+ : 'centrality DESC, size_files DESC';
3108
+ try {
3109
+ const rows = this.db.prepare(`
3110
+ SELECT id, label, size_files AS sizeFiles, size_symbols AS sizeSymbols,
3111
+ primary_language AS primaryLanguage, cohesion, centrality
3112
+ FROM modules
3113
+ ORDER BY ${order}
3114
+ LIMIT ?
3115
+ `).all(limit);
3116
+ return rows.map(r => ({
3117
+ id: toNum(r.id),
3118
+ label: toStr(r.label),
3119
+ sizeFiles: toNum(r.sizeFiles),
3120
+ sizeSymbols: toNum(r.sizeSymbols),
3121
+ primaryLanguage: toNullStr(r.primaryLanguage),
3122
+ cohesion: Number(r.cohesion),
3123
+ centrality: Number(r.centrality),
3124
+ }));
3125
+ }
3126
+ catch {
3127
+ return [];
3128
+ }
3129
+ }
3130
+ getModuleById(id) {
3131
+ if (!this.hasModuleTables)
3132
+ return null;
3133
+ try {
3134
+ const row = this.db.prepare(`
3135
+ SELECT id, label, size_files AS sizeFiles, size_symbols AS sizeSymbols,
3136
+ primary_language AS primaryLanguage, cohesion, centrality
3137
+ FROM modules WHERE id = ?
3138
+ `).get(id);
3139
+ if (!row)
3140
+ return null;
3141
+ return {
3142
+ id: toNum(row.id),
3143
+ label: toStr(row.label),
3144
+ sizeFiles: toNum(row.sizeFiles),
3145
+ sizeSymbols: toNum(row.sizeSymbols),
3146
+ primaryLanguage: toNullStr(row.primaryLanguage),
3147
+ cohesion: Number(row.cohesion),
3148
+ centrality: Number(row.centrality),
3149
+ };
3150
+ }
3151
+ catch {
3152
+ return null;
3153
+ }
3154
+ }
3155
+ /** Module label → row. Used by CLI/MCP module lookups by name. */
3156
+ getModuleByLabel(label) {
3157
+ if (!this.hasModuleTables)
3158
+ return null;
3159
+ try {
3160
+ const row = this.db.prepare(`
3161
+ SELECT id, label, size_files AS sizeFiles, size_symbols AS sizeSymbols,
3162
+ primary_language AS primaryLanguage, cohesion, centrality
3163
+ FROM modules WHERE label = ?
3164
+ `).get(label);
3165
+ if (!row)
3166
+ return null;
3167
+ return {
3168
+ id: toNum(row.id),
3169
+ label: toStr(row.label),
3170
+ sizeFiles: toNum(row.sizeFiles),
3171
+ sizeSymbols: toNum(row.sizeSymbols),
3172
+ primaryLanguage: toNullStr(row.primaryLanguage),
3173
+ cohesion: Number(row.cohesion),
3174
+ centrality: Number(row.centrality),
3175
+ };
3176
+ }
3177
+ catch {
3178
+ return null;
3179
+ }
3180
+ }
3181
+ /**
3182
+ * Files in a module, sorted by file path. Returns empty array if the
3183
+ * module id doesn't exist or modules haven't been built.
3184
+ */
3185
+ listModuleMembers(moduleId, limit = 1000) {
3186
+ if (!this.hasModuleTables)
3187
+ return [];
3188
+ try {
3189
+ const rows = this.db.prepare(`
3190
+ SELECT f.id AS fileId, f.path, f.rel_path AS relPath, f.language, f.role
3191
+ FROM module_members mm
3192
+ JOIN files f ON f.id = mm.file_id
3193
+ WHERE mm.module_id = ?
3194
+ ORDER BY f.rel_path
3195
+ LIMIT ?
3196
+ `).all(moduleId, limit);
3197
+ return rows.map(r => ({
3198
+ fileId: toNum(r.fileId), path: toStr(r.path), relPath: toStr(r.relPath),
3199
+ language: toStr(r.language), role: toStr(r.role),
3200
+ }));
3201
+ }
3202
+ catch {
3203
+ return [];
3204
+ }
3205
+ }
3206
+ /** Top symbols (by PageRank) inside a module. Useful for "what does this module own?" */
3207
+ listModuleTopSymbols(moduleId, limit = 20) {
3208
+ if (!this.hasModuleTables)
3209
+ return [];
3210
+ try {
3211
+ const rows = this.db.prepare(`
3212
+ SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
3213
+ FROM symbols s
3214
+ JOIN files f ON f.id = s.file_id
3215
+ JOIN module_members mm ON mm.file_id = s.file_id
3216
+ WHERE mm.module_id = ? AND s.is_rankable = 1
3217
+ ORDER BY s.pagerank DESC
3218
+ LIMIT ?
3219
+ `).all(moduleId, limit);
3220
+ return rows.map(toSymbolRow);
3221
+ }
3222
+ catch {
3223
+ return [];
3224
+ }
3225
+ }
3226
+ /** Module containing a file id, or null when the file has no membership row. */
3227
+ moduleForFile(fileId) {
3228
+ if (!this.hasModuleTables)
3229
+ return null;
3230
+ try {
3231
+ const row = this.db.prepare(`
3232
+ SELECT m.id, m.label
3233
+ FROM module_members mm JOIN modules m ON m.id = mm.module_id
3234
+ WHERE mm.file_id = ?
3235
+ `).get(fileId);
3236
+ if (!row)
3237
+ return null;
3238
+ return { id: toNum(row.id), label: toStr(row.label) };
3239
+ }
3240
+ catch {
3241
+ return null;
3242
+ }
3243
+ }
3244
+ /**
3245
+ * Cross-module dependency edges. Direction is configurable:
3246
+ * - 'out' (default) → modules this one depends on (from = moduleId)
3247
+ * - 'in' → modules that depend on this one (to = moduleId)
3248
+ * Aggregates across all edge kinds; the kind is preserved per row.
3249
+ */
3250
+ moduleDependencies(moduleId, options = {}) {
3251
+ if (!this.hasModuleTables)
3252
+ return [];
3253
+ const direction = options.direction ?? 'out';
3254
+ const limit = options.limit ?? 100;
3255
+ const sideThis = direction === 'out' ? 'from_module_id' : 'to_module_id';
3256
+ const sideOther = direction === 'out' ? 'to_module_id' : 'from_module_id';
3257
+ try {
3258
+ const rows = this.db.prepare(`
3259
+ SELECT m.id AS moduleId, m.label, me.kind, me.weight
3260
+ FROM module_edges me JOIN modules m ON m.id = me.${sideOther}
3261
+ WHERE me.${sideThis} = ?
3262
+ ORDER BY me.weight DESC
3263
+ LIMIT ?
3264
+ `).all(moduleId, limit);
3265
+ return rows.map(r => ({
3266
+ moduleId: toNum(r.moduleId),
3267
+ label: toStr(r.label),
3268
+ kind: toStr(r.kind),
3269
+ weight: toNum(r.weight),
3270
+ }));
3271
+ }
3272
+ catch {
3273
+ return [];
3274
+ }
3275
+ }
3276
+ // ── Track-E: behavioral / risk helpers ──────────────────────────────────────
3277
+ /**
3278
+ * Raw 'tests' edges into a specific symbol id — id-scoped so short-name
3279
+ * siblings (`Alpha.run` / `Beta.run`) don't share a behavioral contract.
3280
+ * Returns the test-side caller info (name, file, line) so the ranker can
3281
+ * compute path-convention and naming-convention signals without
3282
+ * re-fetching.
3283
+ *
3284
+ * The id-based filter is correct because `synthesizeTestEdges()` now
3285
+ * preserves the source call edge's resolved `to_id` verbatim instead of
3286
+ * re-resolving via `WHERE name = edges.to_name LIMIT 1` (which collapsed
3287
+ * same-short-name symbols).
3288
+ */
3289
+ directTestEdgesForId(symbolId, limit = 200) {
3290
+ if (!this.hasV4Tables)
3291
+ return [];
3292
+ try {
3293
+ const rows = this.db.prepare(`
3294
+ SELECT
3295
+ s.id AS callerId,
3296
+ s.name AS callerName,
3297
+ s.qualified_name AS callerQualifiedName,
3298
+ s.kind AS callerKind,
3299
+ f.path AS callerFile,
3300
+ s.line_start AS callerLineStart,
3301
+ s.line_end AS callerLineEnd,
3302
+ e.line AS edgeLine
3303
+ FROM edges e
3304
+ JOIN symbols s ON s.id = e.from_id
3305
+ JOIN files f ON f.id = s.file_id
3306
+ WHERE e.to_id = ? AND e.kind = 'tests'
3307
+ ORDER BY f.path, e.line
3308
+ LIMIT ?
3309
+ `).all(symbolId, limit);
3310
+ return rows.map(r => ({
3311
+ callerId: toNum(r.callerId),
3312
+ callerName: toStr(r.callerName),
3313
+ callerQualifiedName: toNullStr(r.callerQualifiedName),
3314
+ callerKind: toStr(r.callerKind),
3315
+ callerFile: toStr(r.callerFile),
3316
+ callerLineStart: toNum(r.callerLineStart),
3317
+ callerLineEnd: toNum(r.callerLineEnd),
3318
+ edgeLine: toNum(r.edgeLine),
3319
+ // Computed in JS — needs the file contents.
3320
+ assertionCount: 0,
3321
+ }));
3322
+ }
3323
+ catch {
3324
+ return [];
3325
+ }
3326
+ }
3327
+ /**
3328
+ * Count how many distinct routes have this symbol as their resolved handler.
3329
+ * Used by seer_risk for the "route exposure" signal.
3330
+ */
3331
+ routesForHandler(symbolId) {
3332
+ if (!this.hasV4Tables)
3333
+ return [];
3334
+ try {
3335
+ const rows = this.db.prepare(`
3336
+ SELECT method, path, framework
3337
+ FROM routes WHERE handler_id = ?
3338
+ `).all(symbolId);
3339
+ return rows.map(r => ({
3340
+ method: toStr(r.method), path: toStr(r.path), framework: toStr(r.framework),
3341
+ }));
3342
+ }
3343
+ catch {
3344
+ return [];
3345
+ }
3346
+ }
3347
+ /** Distinct config keys read inside a symbol's body. */
3348
+ configKeysForSymbol(symbolId) {
3349
+ if (!this.hasV4Tables)
3350
+ return [];
3351
+ try {
3352
+ const rows = this.db.prepare(`
3353
+ SELECT DISTINCT key, source, line
3354
+ FROM config_keys WHERE symbol_id = ?
3355
+ ORDER BY line
3356
+ `).all(symbolId);
3357
+ return rows.map(r => ({
3358
+ key: toStr(r.key), source: toStr(r.source), line: toNum(r.line),
3359
+ }));
3360
+ }
3361
+ catch {
3362
+ return [];
3363
+ }
3364
+ }
3365
+ /**
3366
+ * For each call edge OUT of a symbol, return the callee's module id (when
3367
+ * resolved). Used by seer_risk for the "module-boundary crossing" signal.
3368
+ * NULL module ids are filtered out — those are external/unresolved calls.
3369
+ */
3370
+ calleeModulesOf(symbolId) {
3371
+ if (!this.hasModuleTables)
3372
+ return [];
3373
+ try {
3374
+ const rows = this.db.prepare(`
3375
+ SELECT DISTINCT e.to_id AS calleeId, mm.module_id AS moduleId
3376
+ FROM edges e
3377
+ JOIN symbols s ON s.id = e.to_id
3378
+ JOIN module_members mm ON mm.file_id = s.file_id
3379
+ WHERE e.from_id = ? AND e.kind = 'call' AND e.to_id IS NOT NULL
3380
+ `).all(symbolId);
3381
+ return rows.map(r => ({
3382
+ calleeId: toNum(r.calleeId), moduleId: toNum(r.moduleId),
3383
+ }));
3384
+ }
3385
+ catch {
3386
+ return [];
3387
+ }
3388
+ }
3389
+ /**
3390
+ * For each file id, return the symbols that match the given line ranges.
3391
+ * Used by `detect_changes` to compute the blast radius of a diff.
3392
+ */
3393
+ symbolsTouchingLines(fileId, lineRanges) {
3394
+ if (lineRanges.length === 0)
3395
+ return [];
3396
+ const clauses = lineRanges.map(() => '(s.line_start <= ? AND s.line_end >= ?)').join(' OR ');
3397
+ const args = [fileId];
3398
+ for (const [start, end] of lineRanges) {
3399
+ args.push(end); // s.line_start <= rangeEnd
3400
+ args.push(start); // s.line_end >= rangeStart
3401
+ }
3402
+ const rows = this.db.prepare(`
3403
+ SELECT ${symbolSelectCols(this.hasComplexityColumns, this.hasSymbolRoleColumn)}
3404
+ FROM symbols s JOIN files f ON f.id = s.file_id
3405
+ WHERE s.file_id = ? AND (${clauses})
3406
+ ORDER BY s.line_start
3407
+ `).all(...args);
3408
+ return rows.map(toSymbolRow);
3409
+ }
3410
+ // ── Track-F: SCIP imports tracking ──────────────────────────────────────────
3411
+ /**
3412
+ * Record (or refresh) a SCIP import. Returns the row id. UNIQUE on
3413
+ * (path, sha256) — if the same file with the same content is re-imported,
3414
+ * the existing row is kept (the caller's idempotency guarantee).
3415
+ */
3416
+ recordScipImport(scipPath, sha256, tool, projectRoot, symbolCount, refCount) {
3417
+ if (!this.hasV7Columns)
3418
+ return 0;
3419
+ const existing = this.db.prepare('SELECT id FROM scip_imports WHERE path = ? AND sha256 = ?').get(scipPath, sha256);
3420
+ if (existing) {
3421
+ this.db.prepare('UPDATE scip_imports SET imported_at = ?, tool = ?, project_root = ?, symbol_count = ?, ref_count = ? WHERE id = ?').run(Date.now(), tool, projectRoot, symbolCount, refCount, toNum(existing.id));
3422
+ return toNum(existing.id);
3423
+ }
3424
+ const res = this.db.prepare('INSERT INTO scip_imports (path, sha256, tool, project_root, imported_at, symbol_count, ref_count) VALUES (?, ?, ?, ?, ?, ?, ?)').run(scipPath, sha256, tool, projectRoot, Date.now(), symbolCount, refCount);
3425
+ return toNum(res.lastInsertRowid);
3426
+ }
3427
+ /**
3428
+ * Has this exact SCIP file (by sha) been imported already? Lets callers
3429
+ * short-circuit a re-parse on no-op CI re-runs.
3430
+ */
3431
+ hasScipImport(scipPath, sha256) {
3432
+ if (!this.hasV7Columns)
3433
+ return false;
3434
+ const row = this.db.prepare('SELECT 1 FROM scip_imports WHERE path = ? AND sha256 = ?').get(scipPath, sha256);
3435
+ return row != null;
3436
+ }
3437
+ /** Listing for `seer_scip_imports` / the bundle manifest. */
3438
+ listScipImports() {
3439
+ if (!this.hasV7Columns)
3440
+ return [];
3441
+ const rows = this.db.prepare(`
3442
+ SELECT id, path, sha256, tool, project_root AS projectRoot,
3443
+ imported_at AS importedAt, symbol_count AS symbolCount, ref_count AS refCount
3444
+ FROM scip_imports ORDER BY imported_at DESC
3445
+ `).all();
3446
+ return rows.map(r => ({
3447
+ id: toNum(r.id), path: toStr(r.path), sha256: toStr(r.sha256),
3448
+ tool: toNullStr(r.tool), projectRoot: toNullStr(r.projectRoot),
3449
+ importedAt: toNum(r.importedAt),
3450
+ symbolCount: toNum(r.symbolCount), refCount: toNum(r.refCount),
3451
+ }));
3452
+ }
3453
+ /**
3454
+ * Insert (or upsert) a SCIP-sourced symbol. Returns the row id. Uses
3455
+ * (file_id, qualified_name, line_start, kind) as the dedup key when the
3456
+ * existing row was also SCIP-sourced — we never delete tree-sitter rows.
3457
+ * Tree-sitter rows with the same identifier and overlapping line range are
3458
+ * marked 'scip-merge' (precision confirmed by SCIP) instead of being
3459
+ * duplicated, so the agent-facing default lens stays compact.
3460
+ *
3461
+ * `scipImportId` is the `scip_imports.id` row this symbol came from — it
3462
+ * gets persisted on both fresh inserts and merge updates so a later
3463
+ * `clearScipProvenance(path)` can scope its wipe to a single layer instead
3464
+ * of nuking every SCIP row in the DB.
3465
+ */
3466
+ insertOrMergeScipSymbol(fileId, def, scipImportId) {
3467
+ if (!this.hasV7Columns) {
3468
+ const id = this.insertSymbol(fileId, def);
3469
+ return { id, merged: false };
3470
+ }
3471
+ const qualified = def.qualifiedName ?? def.name;
3472
+ // Look for a tree-sitter row with the same qualified name and overlapping
3473
+ // line range — that's the "SCIP confirms our row" case.
3474
+ const existing = this.db.prepare(`
3475
+ SELECT id, provenance FROM symbols
3476
+ WHERE file_id = ?
3477
+ AND (qualified_name = ? OR name = ?)
3478
+ AND kind = ?
3479
+ AND line_start <= ?
3480
+ AND line_end >= ?
3481
+ `).get(fileId, qualified, def.name, def.kind, def.lineEnd, def.lineStart);
3482
+ if (existing) {
3483
+ const existingId = toNum(existing.id);
3484
+ const prov = toStr(existing.provenance);
3485
+ // tree-sitter rows get re-labeled scip-merge AND linked to the import
3486
+ // id, so clearScipProvenance(path) can demote them back. Pre-existing
3487
+ // scip-merge / scip rows keep their original import id so two different
3488
+ // SCIP layers confirming the same tree-sitter row don't fight over it.
3489
+ if (prov === 'tree-sitter') {
3490
+ this.db.prepare("UPDATE symbols SET provenance = 'scip-merge', scip_import_id = ? WHERE id = ?")
3491
+ .run(scipImportId, existingId);
3492
+ }
3493
+ // Stay using the existing id — SCIP-sourced references can point at it.
3494
+ return { id: existingId, merged: true };
3495
+ }
3496
+ // No overlap → insert a fresh SCIP-provenance row.
3497
+ const sig = def.signature ? def.signature.slice(0, 240) : null;
3498
+ const symbolKey = makeSymbolKey(def.kind, qualified);
3499
+ const res = this.db.prepare(`
3500
+ INSERT INTO symbols
3501
+ (name, qualified_name, kind, file_id, line_start, line_end, col_start, col_end,
3502
+ signature, is_rankable, loc, cyclomatic, cognitive, max_nesting, symbol_key, symbol_role, provenance, shape_hash, scip_import_id)
3503
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scip', NULL, ?)
3504
+ `).run(def.name, qualified, def.kind, fileId, def.lineStart, def.lineEnd, def.colStart, def.colEnd, sig, (isRankableKind(def.kind) ? 1 : 0), def.loc ?? null, def.cyclomatic ?? null, def.cognitive ?? null, def.maxNesting ?? null, symbolKey, 'definition', scipImportId);
3505
+ return { id: toNum(res.lastInsertRowid), merged: false };
3506
+ }
3507
+ /**
3508
+ * Insert a SCIP-sourced reference edge. `to_id` is set immediately because
3509
+ * SCIP gives us precise targets — no need for the same-file/imported/global
3510
+ * fallback resolver used for tree-sitter call edges.
3511
+ *
3512
+ * `scipImportId` ties the edge to the contributing SCIP layer so per-layer
3513
+ * wipes are clean.
3514
+ */
3515
+ insertScipEdge(fromSymbolId, toSymbolId, toName, kind, line, scipImportId) {
3516
+ if (!this.hasV7Columns)
3517
+ return;
3518
+ this.db.prepare("INSERT INTO edges (from_id, to_name, to_id, kind, line, provenance, scip_import_id) VALUES (?, ?, ?, ?, ?, 'scip', ?)").run(fromSymbolId, toName, toSymbolId, kind, line, scipImportId);
3519
+ }
3520
+ /**
3521
+ * Wipe SCIP-sourced rows so a fresh import can replace them. Tree-sitter
3522
+ * rows are preserved; only the rows that came from the specified SCIP layer
3523
+ * are touched.
3524
+ *
3525
+ * - scipPath omitted → ALL SCIP layers are wiped (every scip-provenance
3526
+ * row is dropped, every scip-merge row demoted to tree-sitter, and
3527
+ * the scip_imports table emptied). Useful for "I want my baseline
3528
+ * back."
3529
+ * - scipPath provided → only rows linked to scip_imports.id for that
3530
+ * path are touched. Sibling layers stay intact. This is what
3531
+ * importScip() calls before re-ingesting the same path, so a
3532
+ * multi-layer setup (rust+ts SCIPs) stays correct on partial refresh.
3533
+ */
3534
+ clearScipProvenance(scipPath) {
3535
+ if (!this.hasV7Columns)
3536
+ return 0;
3537
+ let edgeDeletes = 0, symDeletes = 0;
3538
+ this.db.exec('BEGIN');
3539
+ try {
3540
+ if (scipPath == null) {
3541
+ // Global wipe — every SCIP layer collapses.
3542
+ this.db.exec("UPDATE symbols SET provenance = 'tree-sitter', scip_import_id = NULL WHERE provenance = 'scip-merge'");
3543
+ const eRes = this.db.prepare("DELETE FROM edges WHERE provenance = 'scip'").run();
3544
+ edgeDeletes = toNum(eRes.changes);
3545
+ const sRes = this.db.prepare("DELETE FROM symbols WHERE provenance = 'scip'").run();
3546
+ symDeletes = toNum(sRes.changes);
3547
+ this.db.exec('DELETE FROM scip_imports');
3548
+ }
3549
+ else {
3550
+ // Per-layer wipe — look up the import id for this path. If there's
3551
+ // no row, treat it as "nothing to do" rather than failing (callers
3552
+ // can blindly call clearScipProvenance(path) before insertion).
3553
+ const rows = this.db.prepare('SELECT id FROM scip_imports WHERE path = ?').all(scipPath);
3554
+ const ids = rows.map(r => toNum(r.id));
3555
+ if (ids.length > 0) {
3556
+ const ph = ids.map(() => '?').join(',');
3557
+ this.db.prepare(`UPDATE symbols SET provenance = 'tree-sitter', scip_import_id = NULL
3558
+ WHERE provenance = 'scip-merge' AND scip_import_id IN (${ph})`).run(...ids);
3559
+ const eRes = this.db.prepare(`DELETE FROM edges WHERE provenance = 'scip' AND scip_import_id IN (${ph})`).run(...ids);
3560
+ edgeDeletes = toNum(eRes.changes);
3561
+ const sRes = this.db.prepare(`DELETE FROM symbols WHERE provenance = 'scip' AND scip_import_id IN (${ph})`).run(...ids);
3562
+ symDeletes = toNum(sRes.changes);
3563
+ this.db.prepare('DELETE FROM scip_imports WHERE path = ?').run(scipPath);
3564
+ }
3565
+ }
3566
+ this.db.exec('COMMIT');
3567
+ }
3568
+ catch (err) {
3569
+ this.db.exec('ROLLBACK');
3570
+ throw err;
3571
+ }
3572
+ return symDeletes + edgeDeletes;
3573
+ }
3574
+ /** Provenance breakdown for `seer_health` / `seer_stats`. */
3575
+ getProvenanceCounts() {
3576
+ const out = {
3577
+ symbols: { 'tree-sitter': 0, scip: 0, 'scip-merge': 0 },
3578
+ edges: { 'tree-sitter': 0, scip: 0, 'scip-merge': 0 },
3579
+ };
3580
+ if (!this.hasV7Columns)
3581
+ return out;
3582
+ try {
3583
+ for (const r of this.db.prepare('SELECT provenance, COUNT(*) AS c FROM symbols GROUP BY provenance').all()) {
3584
+ out.symbols[toStr(r.provenance)] = toNum(r.c);
3585
+ }
3586
+ for (const r of this.db.prepare('SELECT provenance, COUNT(*) AS c FROM edges GROUP BY provenance').all()) {
3587
+ out.edges[toStr(r.provenance)] = toNum(r.c);
3588
+ }
3589
+ }
3590
+ catch { /* */ }
3591
+ return out;
3592
+ }
3593
+ // ── Track-F: shape-hash (structural SimHash) ────────────────────────────────
3594
+ /** Set a symbol's shape_hash. NULL clears it. Persisted as INTEGER. */
3595
+ setShapeHash(symbolId, hash) {
3596
+ if (!this.hasV7Columns)
3597
+ return;
3598
+ // node:sqlite accepts bigint for INTEGER columns; convert to signed range.
3599
+ const value = hash == null ? null : toSignedI64(hash);
3600
+ this.db.prepare('UPDATE symbols SET shape_hash = ? WHERE id = ?').run(value, symbolId);
3601
+ }
3602
+ /**
3603
+ * Fetch all symbols that have a non-null shape_hash. Used as the candidate
3604
+ * pool for duplicate detection. Returns minimal fields to keep the working
3605
+ * set small on huge codebases.
3606
+ */
3607
+ listSymbolsWithShapeHash(opts = {}) {
3608
+ if (!this.hasV7Columns)
3609
+ return [];
3610
+ const conds = ['s.shape_hash IS NOT NULL'];
3611
+ const args = [];
3612
+ if (opts.minLoc != null) {
3613
+ conds.push('s.loc >= ?');
3614
+ args.push(opts.minLoc);
3615
+ }
3616
+ if (opts.includeTests === false) {
3617
+ conds.push("f.role <> 'test'");
3618
+ }
3619
+ const limit = opts.limit ?? 50000;
3620
+ args.push(limit);
3621
+ const stmt = this.db.prepare(`
3622
+ SELECT s.id, s.name, s.qualified_name AS qualifiedName, s.kind,
3623
+ f.path AS filePath, s.line_start AS lineStart, s.line_end AS lineEnd,
3624
+ s.loc, s.shape_hash AS shapeHash
3625
+ FROM symbols s JOIN files f ON f.id = s.file_id
3626
+ WHERE ${conds.join(' AND ')}
3627
+ ORDER BY s.id
3628
+ LIMIT ?
3629
+ `);
3630
+ // shape_hash regularly overflows JS safe-integer range; without this flag
3631
+ // node:sqlite throws on row materialization, which the outer try-catch
3632
+ // would swallow into an empty result. We opt the entire row into bigint
3633
+ // and convert the small-int columns back to plain numbers.
3634
+ try {
3635
+ stmt.setReadBigInts(true);
3636
+ }
3637
+ catch { /* */ }
3638
+ try {
3639
+ const rows = stmt.all(...args);
3640
+ return rows.map(r => ({
3641
+ id: toNum(r.id),
3642
+ name: toStr(r.name),
3643
+ qualifiedName: toNullStr(r.qualifiedName),
3644
+ kind: toStr(r.kind),
3645
+ filePath: toStr(r.filePath),
3646
+ lineStart: toNum(r.lineStart),
3647
+ lineEnd: toNum(r.lineEnd),
3648
+ loc: toNullNum(r.loc),
3649
+ shapeHash: toUnsignedI64(r.shapeHash),
3650
+ }));
3651
+ }
3652
+ catch {
3653
+ return [];
3654
+ }
3655
+ }
3656
+ /** v7 read-flag accessor for downstream features that need to gate on it. */
3657
+ hasV7() { return this.hasV7Columns; }
3658
+ /**
3659
+ * Are there function-like symbols (kind function/method/constructor, role
3660
+ * not 'declaration', loc >= 4) that don't yet have a shape_hash? Used by
3661
+ * the indexer to decide whether to run buildShapeHashes() on a cached
3662
+ * re-run — when a pre-v7 DB migrates to v7, every existing row still has
3663
+ * shape_hash NULL even though the file is "cached" (its content hash
3664
+ * didn't change), so the normal graphChanged predicate misses the
3665
+ * backfill. This check catches that.
3666
+ */
3667
+ hasMissingShapeHashes(minLoc = 4) {
3668
+ if (!this.hasV7Columns)
3669
+ return false;
3670
+ try {
3671
+ const row = this.db.prepare(`
3672
+ SELECT 1 FROM symbols
3673
+ WHERE shape_hash IS NULL
3674
+ AND kind IN ('function','method','constructor')
3675
+ AND symbol_role <> 'declaration'
3676
+ AND loc >= ?
3677
+ LIMIT 1
3678
+ `).get(minLoc);
3679
+ return row != null;
3680
+ }
3681
+ catch {
3682
+ return false;
3683
+ }
3684
+ }
3685
+ // ── Stats ───────────────────────────────────────────────────────────────────
3686
+ getStats() {
3687
+ const files = toNum(this.db.prepare('SELECT COUNT(*) AS c FROM files').get().c);
3688
+ const symbols = toNum(this.db.prepare('SELECT COUNT(*) AS c FROM symbols').get().c);
3689
+ const edges = toNum(this.db.prepare("SELECT COUNT(*) AS c FROM edges WHERE kind = 'call'").get().c);
3690
+ const resolvedEdges = toNum(this.db.prepare("SELECT COUNT(*) AS c FROM edges WHERE to_id IS NOT NULL AND kind = 'call'").get().c);
3691
+ const langRows = this.db.prepare('SELECT language, COUNT(*) AS c FROM files GROUP BY language').all();
3692
+ const languages = {};
3693
+ for (const r of langRows)
3694
+ languages[toStr(r.language)] = toNum(r.c);
3695
+ let routes = 0, externalDependencies = 0, configKeys = 0, symbolHistory = 0, modules = 0;
3696
+ try {
3697
+ routes = this.countRoutes();
3698
+ }
3699
+ catch { /* */ }
3700
+ try {
3701
+ externalDependencies = this.countExternalDeps();
3702
+ }
3703
+ catch { /* */ }
3704
+ try {
3705
+ configKeys = this.countConfigKeys();
3706
+ }
3707
+ catch { /* */ }
3708
+ try {
3709
+ if (this.hasV4Tables) {
3710
+ symbolHistory = toNum(this.db.prepare('SELECT COUNT(*) AS c FROM symbol_history').get().c);
3711
+ }
3712
+ }
3713
+ catch { /* */ }
3714
+ try {
3715
+ modules = this.countModules();
3716
+ }
3717
+ catch { /* */ }
3718
+ // v7 extras — provenance breakdown and SCIP imports + shape_hash coverage.
3719
+ let scipImports = 0;
3720
+ let shapeHashed = 0;
3721
+ if (this.hasV7Columns) {
3722
+ try {
3723
+ scipImports = toNum(this.db.prepare('SELECT COUNT(*) AS c FROM scip_imports').get().c);
3724
+ }
3725
+ catch { /* */ }
3726
+ try {
3727
+ shapeHashed = toNum(this.db.prepare('SELECT COUNT(*) AS c FROM symbols WHERE shape_hash IS NOT NULL').get().c);
3728
+ }
3729
+ catch { /* */ }
3730
+ }
3731
+ // v8 Track G — service-link counts.
3732
+ let serviceCalls = 0;
3733
+ let serviceLinks = 0;
3734
+ try {
3735
+ serviceCalls = this.countServiceCalls();
3736
+ }
3737
+ catch { /* */ }
3738
+ try {
3739
+ serviceLinks = this.countServiceLinks();
3740
+ }
3741
+ catch { /* */ }
3742
+ return {
3743
+ files, symbols, edges, resolvedEdges, languages,
3744
+ roles: this.getRoleCounts(),
3745
+ routes,
3746
+ externalDependencies,
3747
+ configKeys,
3748
+ symbolHistory,
3749
+ modules,
3750
+ scipImports,
3751
+ shapeHashed,
3752
+ provenance: this.getProvenanceCounts(),
3753
+ serviceCalls,
3754
+ serviceLinks,
3755
+ };
3756
+ }
3757
+ /** Direct access to the underlying DB for niche callers (history indexer). */
3758
+ rawDb() { return this.db; }
3759
+ begin() { this.db.exec('BEGIN'); }
3760
+ commit() { this.db.exec('COMMIT'); }
3761
+ rollback() { this.db.exec('ROLLBACK'); }
3762
+ close() {
3763
+ this.db.close();
3764
+ }
3765
+ }
3766
+ exports.Store = Store;
3767
+ function symbolSelectCols(hasComplexity, hasSymbolRole) {
3768
+ let cols = `s.id, s.name, s.qualified_name AS qualifiedName, s.kind, s.file_id AS fileId,
3769
+ f.path AS filePath, s.line_start AS lineStart,
3770
+ s.line_end AS lineEnd, s.signature, s.pagerank`;
3771
+ if (hasComplexity)
3772
+ cols += `, s.loc, s.cyclomatic, s.cognitive, s.max_nesting AS maxNesting`;
3773
+ if (hasSymbolRole)
3774
+ cols += `, s.symbol_role AS symbolRole`;
3775
+ return cols;
3776
+ }
3777
+ function toSymbolRow(r) {
3778
+ return {
3779
+ id: toNum(r.id),
3780
+ name: toStr(r.name),
3781
+ qualifiedName: toNullStr(r.qualifiedName),
3782
+ kind: toStr(r.kind),
3783
+ fileId: toNum(r.fileId),
3784
+ filePath: toStr(r.filePath),
3785
+ lineStart: toNum(r.lineStart),
3786
+ lineEnd: toNum(r.lineEnd),
3787
+ signature: toNullStr(r.signature),
3788
+ pagerank: toNum(r.pagerank),
3789
+ loc: toNullNum(r.loc),
3790
+ cyclomatic: toNullNum(r.cyclomatic),
3791
+ cognitive: toNullNum(r.cognitive),
3792
+ maxNesting: toNullNum(r.maxNesting),
3793
+ symbolRole: r.symbolRole == null ? null : toStr(r.symbolRole),
3794
+ };
3795
+ }
3796
+ /**
3797
+ * Build a stable symbol-history key for a symbol. The shape is
3798
+ * `kind:qualified_name` — coarse on purpose so a function rename within a
3799
+ * file collapses history to the new name (we'd rather lose precision than
3800
+ * lose history entirely when extractors disagree about parameter shape).
3801
+ *
3802
+ * Future: include parameter arity or signature-hash for overload distinction.
3803
+ */
3804
+ function makeSymbolKey(kind, qualifiedName) {
3805
+ return `${kind}:${qualifiedName}`;
3806
+ }
3807
+ /**
3808
+ * Build an FTS5 MATCH expression from a free-text query. Strategy:
3809
+ * - lower-case
3810
+ * - split on whitespace and identifier punctuation
3811
+ * - quote each non-empty token and OR them together with `*` for prefix
3812
+ *
3813
+ * Empty / invalid → null (the caller falls back to LIKE).
3814
+ */
3815
+ function ftsQuery(input) {
3816
+ if (!input)
3817
+ return null;
3818
+ const tokens = splitIdentifierTokens(input)
3819
+ .split(/\s+/)
3820
+ .filter(t => t.length > 0 && /^[a-z0-9]/i.test(t))
3821
+ .map(t => t.replace(/["'*]/g, ''))
3822
+ .filter(t => t.length > 0);
3823
+ if (tokens.length === 0)
3824
+ return null;
3825
+ return tokens.map(t => `"${t}"*`).join(' OR ');
3826
+ }
3827
+ // ── Import path resolution ───────────────────────────────────────────────────
3828
+ function normalizePath(p) {
3829
+ return p.replace(/\\/g, '/');
3830
+ }
3831
+ const TS_JS_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
3832
+ function resolveImportToFileId(fromPath, language, importName, fileByPath) {
3833
+ if (language === 'typescript' || language === 'javascript') {
3834
+ return resolveJsImport(fromPath, importName, fileByPath);
3835
+ }
3836
+ if (language === 'python') {
3837
+ return resolvePythonImport(fromPath, importName, fileByPath);
3838
+ }
3839
+ return null;
3840
+ }
3841
+ function resolveJsImport(fromPath, importName, fileByPath) {
3842
+ if (!importName.startsWith('./') && !importName.startsWith('../'))
3843
+ return null;
3844
+ const fromDir = path_1.default.dirname(fromPath);
3845
+ const target = path_1.default.resolve(fromDir, importName);
3846
+ const ext = path_1.default.extname(target);
3847
+ if (ext && TS_JS_EXTS.includes(ext)) {
3848
+ const id = fileByPath.get(normalizePath(target));
3849
+ if (id !== undefined)
3850
+ return id;
3851
+ }
3852
+ for (const e of TS_JS_EXTS) {
3853
+ const id = fileByPath.get(normalizePath(target + e));
3854
+ if (id !== undefined)
3855
+ return id;
3856
+ }
3857
+ for (const e of TS_JS_EXTS) {
3858
+ const id = fileByPath.get(normalizePath(path_1.default.join(target, 'index' + e)));
3859
+ if (id !== undefined)
3860
+ return id;
3861
+ }
3862
+ return null;
3863
+ }
3864
+ function resolvePythonImport(fromPath, importName, fileByPath) {
3865
+ if (!importName.startsWith('.'))
3866
+ return null;
3867
+ let levelsUp = 0;
3868
+ while (levelsUp < importName.length && importName[levelsUp] === '.') {
3869
+ levelsUp++;
3870
+ }
3871
+ const modulePath = importName.slice(levelsUp);
3872
+ if (modulePath.length === 0)
3873
+ return null;
3874
+ let baseDir = path_1.default.dirname(fromPath);
3875
+ for (let i = 1; i < levelsUp; i++) {
3876
+ baseDir = path_1.default.dirname(baseDir);
3877
+ }
3878
+ const parts = modulePath.split('.');
3879
+ const target = path_1.default.join(baseDir, ...parts);
3880
+ const fileCandidate = fileByPath.get(normalizePath(target + '.py'));
3881
+ if (fileCandidate !== undefined)
3882
+ return fileCandidate;
3883
+ const pkgCandidate = fileByPath.get(normalizePath(path_1.default.join(target, '__init__.py')));
3884
+ if (pkgCandidate !== undefined)
3885
+ return pkgCandidate;
3886
+ return null;
3887
+ }
3888
+ //# sourceMappingURL=store.js.map