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,1372 @@
1
+ /**
2
+ * Track G — Service Links feature tests.
3
+ *
4
+ * Builds incrementally: Step 1 verifies the v8 schema migration, table layout,
5
+ * and FK cascade. Later steps add HTTP-client extraction, URL normalization,
6
+ * the post-index resolver, the Store APIs, and the CLI/MCP surface.
7
+ *
8
+ * Run: npx tsx tests/trackg.ts
9
+ */
10
+
11
+ import path from 'path';
12
+ import fs from 'fs';
13
+ import os from 'os';
14
+ import { Indexer } from '../src/indexer/index';
15
+ import { Store } from '../src/db/store';
16
+ import { CURRENT_SCHEMA_VERSION } from '../src/db/schema';
17
+ import { normalizeHttpTarget, routePatternsMatch, methodMatchScore } from '../src/indexer/serviceLinks';
18
+ import { computeRisk } from '../src/indexer/risk';
19
+ import { buildContext } from '../src/indexer/context';
20
+
21
+ const FIX_SERVICE = path.join(__dirname, 'fixtures-service');
22
+
23
+ const TMP_DIR = path.join(os.tmpdir(), `seer-trackg-${Date.now()}`);
24
+
25
+ let passed = 0;
26
+ let failed = 0;
27
+ function assert(cond: boolean, msg: string): void {
28
+ if (cond) { console.log(` ✓ ${msg}`); passed++; }
29
+ else { console.error(` ✗ ${msg}`); failed++; }
30
+ }
31
+ function assertEq<T>(actual: T, expected: T, msg: string): void {
32
+ assert(actual === expected,
33
+ `${msg} (got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)})`);
34
+ }
35
+
36
+ function rawColumns(s: Store, table: string): string[] {
37
+ return (s.rawDb().prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>)
38
+ .map(r => r.name);
39
+ }
40
+
41
+ async function main(): Promise<void> {
42
+ console.log('\nSeer Track G — Step 1: Schema v9 (Track-H protocol expansion)');
43
+ console.log('===================================\n');
44
+ fs.mkdirSync(TMP_DIR, { recursive: true });
45
+
46
+ // ── 1a: Fresh DB lands at CURRENT_SCHEMA_VERSION = 9 ───────────────────
47
+ console.log('── Fresh DB schema ──');
48
+ const freshDb = path.join(TMP_DIR, 'fresh.db');
49
+ const fresh = new Store(freshDb);
50
+ try {
51
+ assertEq(CURRENT_SCHEMA_VERSION, 10, 'CURRENT_SCHEMA_VERSION = 10');
52
+ const info = fresh.schemaInfo();
53
+ assertEq(info.dbVersion, 10, 'fresh DB dbVersion = 9');
54
+ assertEq(info.current, true, 'fresh DB schema.current = true');
55
+
56
+ const scCols = rawColumns(fresh, 'service_calls');
57
+ for (const c of [
58
+ 'id', 'file_id', 'symbol_id', 'protocol', 'method',
59
+ 'raw_target', 'normalized_path', 'host_hint', 'env_key',
60
+ 'framework', 'line', 'confidence',
61
+ // v9 Track-H generalized fields
62
+ 'operation', 'topic', 'queue', 'exchange', 'service', 'broker', 'metadata_json',
63
+ ]) assert(scCols.includes(c), `service_calls has column ${c}`);
64
+
65
+ const slCols = rawColumns(fresh, 'service_links');
66
+ for (const c of [
67
+ 'id', 'call_id', 'route_id', 'caller_symbol_id', 'handler_symbol_id',
68
+ 'protocol', 'match_kind', 'confidence', 'evidence_json',
69
+ ]) assert(slCols.includes(c), `service_links has column ${c}`);
70
+
71
+ // v9 Track-H — routes table gains the same generalized fields.
72
+ const rCols = rawColumns(fresh, 'routes');
73
+ for (const c of [
74
+ 'id', 'file_id', 'method', 'path', 'framework', 'handler_name', 'handler_id', 'line',
75
+ 'protocol', 'operation', 'topic', 'queue', 'exchange', 'service', 'broker', 'metadata_json',
76
+ ]) assert(rCols.includes(c), `routes has column ${c}`);
77
+
78
+ // Empty tables on first open.
79
+ const sc = fresh.rawDb().prepare('SELECT COUNT(*) AS c FROM service_calls').get() as { c: number };
80
+ const sl = fresh.rawDb().prepare('SELECT COUNT(*) AS c FROM service_links').get() as { c: number };
81
+ assertEq(sc.c, 0, 'service_calls is empty on fresh DB');
82
+ assertEq(sl.c, 0, 'service_links is empty on fresh DB');
83
+
84
+ // Required indexes
85
+ const idx = (fresh.rawDb().prepare(
86
+ `SELECT name FROM sqlite_master WHERE type='index'`
87
+ ).all() as Array<{ name: string }>).map(r => r.name);
88
+ for (const n of [
89
+ 'idx_service_calls_symbol_id', 'idx_service_calls_path',
90
+ 'idx_service_calls_protocol', 'idx_service_calls_file_id',
91
+ 'idx_service_links_call_id', 'idx_service_links_handler',
92
+ 'idx_service_links_caller', 'idx_service_links_protocol',
93
+ 'idx_service_links_match_kind',
94
+ ]) assert(idx.includes(n), `index ${n} exists`);
95
+ } finally { fresh.close(); }
96
+
97
+ // ── 1b: Pre-v8 DB migrates and gets empty service_* tables ─────────────
98
+ console.log('\n── Migration from a pre-v8 DB ──');
99
+ const migDb = path.join(TMP_DIR, 'migrate.db');
100
+ {
101
+ // Hand-craft a "fake" pre-v8 DB: open through Store (which will install
102
+ // schema v9), then drop the v8 tables and rewind the schema_version.
103
+ // Re-opening Store should re-add the tables and bump back to v9.
104
+ const seed = new Store(migDb);
105
+ seed.rawDb().exec('DROP TABLE IF EXISTS service_links');
106
+ seed.rawDb().exec('DROP TABLE IF EXISTS service_calls');
107
+ seed.rawDb().prepare(
108
+ `UPDATE _schema_meta SET value = '7' WHERE key = 'schema_version'`,
109
+ ).run();
110
+ seed.close();
111
+ }
112
+ const migrated = new Store(migDb);
113
+ try {
114
+ const info = migrated.schemaInfo();
115
+ assertEq(info.dbVersion, 10, 'migrated DB version bumped to v9');
116
+ const sc = migrated.rawDb().prepare('SELECT COUNT(*) AS c FROM service_calls').get() as { c: number };
117
+ const sl = migrated.rawDb().prepare('SELECT COUNT(*) AS c FROM service_links').get() as { c: number };
118
+ assertEq(sc.c, 0, 'service_calls exists after migration');
119
+ assertEq(sl.c, 0, 'service_links exists after migration');
120
+ // v9: generalized columns must be present on the migrated DB.
121
+ const scCols = rawColumns(migrated, 'service_calls');
122
+ for (const c of ['operation', 'topic', 'queue', 'service', 'metadata_json']) {
123
+ assert(scCols.includes(c), `migrated service_calls has v9 column ${c}`);
124
+ }
125
+ const rCols = rawColumns(migrated, 'routes');
126
+ for (const c of ['protocol', 'operation', 'topic', 'queue', 'metadata_json']) {
127
+ assert(rCols.includes(c), `migrated routes has v9 column ${c}`);
128
+ }
129
+ } finally { migrated.close(); }
130
+
131
+ // ── 1b': Pre-v9 (v8) DB migrates in-place, existing HTTP rows preserved ──
132
+ console.log('\n── Migration from a v8 DB (in-place) ──');
133
+ const v8MigDb = path.join(TMP_DIR, 'v8migrate.db');
134
+ {
135
+ const seed = new Store(v8MigDb);
136
+ await new Indexer(seed).indexDirectory(FIX_SERVICE, { quiet: true });
137
+ const before = seed.rawDb().prepare('SELECT COUNT(*) AS c FROM service_calls').get() as { c: number };
138
+ const beforeRoutes = seed.rawDb().prepare('SELECT COUNT(*) AS c FROM routes').get() as { c: number };
139
+ assert(before.c > 0, 'v8 seed has service_calls');
140
+ assert(beforeRoutes.c > 0, 'v8 seed has routes');
141
+ // Simulate a v8 DB by dropping the v9 columns (we drop them by recreating
142
+ // the bare v8 table shapes — SQLite < 3.35 has no DROP COLUMN). We just
143
+ // rewind the schema_version marker and trust ALTER ADD to be idempotent.
144
+ seed.rawDb().prepare(
145
+ `UPDATE _schema_meta SET value = '8' WHERE key = 'schema_version'`,
146
+ ).run();
147
+ seed.close();
148
+ }
149
+ const v8Migrated = new Store(v8MigDb);
150
+ try {
151
+ const info = v8Migrated.schemaInfo();
152
+ assertEq(info.dbVersion, 10, 'v9 DB version bumped to v10 in-place');
153
+ const after = v8Migrated.rawDb().prepare('SELECT COUNT(*) AS c FROM service_calls').get() as { c: number };
154
+ const afterRoutes = v8Migrated.rawDb().prepare('SELECT COUNT(*) AS c FROM routes').get() as { c: number };
155
+ assert(after.c > 0, 'v8 rows preserved through v9 migration');
156
+ assert(afterRoutes.c > 0, 'v8 routes preserved through v9 migration');
157
+ // Every route row must have a non-null protocol after migration (the
158
+ // column was added with DEFAULT 'http' so pre-existing HTTP rows get
159
+ // that value; v9-aware extractor rows already carry their actual proto).
160
+ const nullProto = v8Migrated.rawDb().prepare(
161
+ `SELECT COUNT(*) AS c FROM routes WHERE protocol IS NULL`,
162
+ ).get() as { c: number };
163
+ assertEq(nullProto.c, 0,
164
+ 'no route has NULL protocol after v9 migration');
165
+ // The HTTP fraction must be non-empty — otherwise the DEFAULT 'http'
166
+ // wouldn't have any rows to apply to and the migration assertion is
167
+ // toothless.
168
+ const httpRoutes = v8Migrated.rawDb().prepare(
169
+ `SELECT COUNT(*) AS c FROM routes WHERE protocol = 'http'`,
170
+ ).get() as { c: number };
171
+ assert(httpRoutes.c > 0,
172
+ `at least one route has protocol=http after migration (got ${httpRoutes.c})`);
173
+ } finally { v8Migrated.close(); }
174
+
175
+ // v8 backfill regression: a pre-v8 DB already has file hashes, so a normal
176
+ // cached re-index would skip parsing every file and leave service_calls
177
+ // empty forever. The indexer must force one parse pass and then mark it done.
178
+ console.log('\n-- Cached migration service-call backfill --');
179
+ const backfillDb = path.join(TMP_DIR, 'backfill.db');
180
+ {
181
+ const seed = new Store(backfillDb);
182
+ await new Indexer(seed).indexDirectory(FIX_SERVICE, { quiet: true });
183
+ const seeded = seed.rawDb().prepare('SELECT COUNT(*) AS c FROM service_calls').get() as { c: number };
184
+ assert(seeded.c > 0, `seed DB has service_calls before simulating v7 (got ${seeded.c})`);
185
+ seed.rawDb().exec(`
186
+ DROP TABLE IF EXISTS service_links;
187
+ DROP TABLE IF EXISTS service_calls;
188
+ DELETE FROM _schema_meta WHERE key = 'service_calls_backfilled';
189
+ UPDATE _schema_meta SET value = '7' WHERE key = 'schema_version';
190
+ `);
191
+ seed.close();
192
+ }
193
+ const backfill = new Store(backfillDb);
194
+ try {
195
+ assert(backfill.needsServiceCallBackfill(), 'v7->v9 DB reports service-call backfill needed');
196
+ const r = await new Indexer(backfill).indexDirectory(FIX_SERVICE, { quiet: true });
197
+ const restored = backfill.rawDb().prepare('SELECT COUNT(*) AS c FROM service_calls').get() as { c: number };
198
+ const links = backfill.rawDb().prepare('SELECT COUNT(*) AS c FROM service_links').get() as { c: number };
199
+ assert(r.filesIndexed > 0, `backfill forced reparsing despite unchanged hashes (indexed ${r.filesIndexed})`);
200
+ assertEq(r.filesReusedFromCache, 0, 'backfill run does not take cached fast path');
201
+ assert(restored.c > 0, `service_calls restored on cached migration (got ${restored.c})`);
202
+ assert(links.c > 0, `service_links rebuilt on cached migration (got ${links.c})`);
203
+ assert(!backfill.needsServiceCallBackfill(), 'service-call backfill marker written after successful run');
204
+ } finally { backfill.close(); }
205
+
206
+ // ── 1c: FK cascade — deleting a file removes its service rows ────────
207
+ console.log('\n── FK cascade ──');
208
+ const cascDb = path.join(TMP_DIR, 'cascade.db');
209
+ const c = new Store(cascDb);
210
+ try {
211
+ const raw = c.rawDb();
212
+ const insertFile = raw.prepare(
213
+ 'INSERT INTO files(path, rel_path, language, hash, lines, indexed_at) VALUES (?, ?, ?, ?, ?, ?)'
214
+ );
215
+ const r1 = insertFile.run('/tmp/x.ts', 'x.ts', 'typescript', 'abc', 10, Date.now());
216
+ const fileId = Number(r1.lastInsertRowid);
217
+
218
+ const ins = raw.prepare(`INSERT INTO service_calls
219
+ (file_id, symbol_id, protocol, method, raw_target, normalized_path,
220
+ host_hint, env_key, framework, line, confidence)
221
+ VALUES (?, NULL, ?, ?, ?, ?, NULL, NULL, ?, ?, ?)`)
222
+ .run(fileId, 'http', 'GET', '/api/users', '/api/users', 'fetch', 5, 0.9);
223
+ const callId = Number(ins.lastInsertRowid);
224
+
225
+ raw.prepare(`INSERT INTO service_links
226
+ (call_id, route_id, caller_symbol_id, handler_symbol_id, protocol, match_kind, confidence, evidence_json)
227
+ VALUES (?, NULL, NULL, NULL, 'http', 'literal_path', 0.95, '{}')`)
228
+ .run(callId);
229
+
230
+ const before = raw.prepare('SELECT COUNT(*) AS c FROM service_calls').get() as { c: number };
231
+ const beforeLinks = raw.prepare('SELECT COUNT(*) AS c FROM service_links').get() as { c: number };
232
+ assertEq(before.c, 1, 'service_calls inserted');
233
+ assertEq(beforeLinks.c, 1, 'service_links inserted');
234
+
235
+ raw.prepare('DELETE FROM files WHERE id = ?').run(fileId);
236
+ const after = raw.prepare('SELECT COUNT(*) AS c FROM service_calls').get() as { c: number };
237
+ const afterLinks = raw.prepare('SELECT COUNT(*) AS c FROM service_links').get() as { c: number };
238
+ assertEq(after.c, 0, 'service_calls cascade-deleted with file');
239
+ assertEq(afterLinks.c, 0, 'service_links cascade-deleted via service_calls');
240
+ } finally { c.close(); }
241
+
242
+ // ── Step 3: TypeScript HTTP client extraction ─────────────────────────
243
+ console.log('\n── Step 3: TS HTTP client extraction ──');
244
+ const tsDb = path.join(TMP_DIR, 'ts.db');
245
+ const tsStore = new Store(tsDb);
246
+ await new Indexer(tsStore).indexDirectory(FIX_SERVICE, { quiet: true });
247
+ try {
248
+ const raw = tsStore.rawDb();
249
+ type SCRow = {
250
+ framework: string; method: string | null; raw_target: string;
251
+ env_key: string | null; line: number; symbol_id: number | null;
252
+ };
253
+ const rows = raw.prepare(
254
+ `SELECT sc.framework, sc.method, sc.raw_target, sc.env_key, sc.line, sc.symbol_id
255
+ FROM service_calls sc JOIN files f ON f.id = sc.file_id
256
+ WHERE f.language = 'typescript'
257
+ ORDER BY sc.line ASC`
258
+ ).all() as SCRow[];
259
+ console.log(` → ${rows.length} TS service_calls extracted`);
260
+
261
+ // listUsers → fetch('/api/users')
262
+ const list = rows.find(r => r.raw_target === '/api/users' && r.framework === 'fetch');
263
+ assert(!!list, 'fetch("/api/users") recorded');
264
+ if (list) assertEq(list.method, 'ANY', 'plain fetch defaults to ANY method');
265
+
266
+ // createUser → fetch('/api/users', {method:'POST'})
267
+ const create = rows.find(r => r.raw_target === '/api/users' && r.method === 'POST');
268
+ assert(!!create, 'fetch("/api/users", {method:"POST"}) recorded with POST');
269
+
270
+ // checkout → axios.post('/checkout')
271
+ const checkout = rows.find(r => r.raw_target === '/checkout');
272
+ assert(!!checkout, 'axios.post("/checkout") recorded');
273
+ if (checkout) {
274
+ assertEq(checkout.framework, 'axios', 'axios framework labelled');
275
+ assertEq(checkout.method, 'POST', 'method derived from .post');
276
+ }
277
+
278
+ // fetchOrders → apiClient.get('/api/orders')
279
+ const orders = rows.find(r => r.raw_target === '/api/orders');
280
+ assert(!!orders, 'apiClient.get("/api/orders") recorded');
281
+ if (orders) assertEq(orders.framework, 'http-client', 'generic client.get → http-client');
282
+
283
+ // chargeCustomer → fetch(`${process.env.PAYMENT_URL}/charge`)
284
+ const charge = rows.find(r => r.raw_target === '/charge' && r.framework === 'fetch');
285
+ assert(!!charge, 'template literal "/charge" path lifted');
286
+ if (charge) assertEq(charge.env_key, 'PAYMENT_URL', 'PAYMENT_URL env var captured');
287
+
288
+ // dynamicUrl(u) → fetch(u) — NOT recorded (no string literal arg).
289
+ // Check: no row references the parameter name "u" as raw_target.
290
+ const dyn = rows.find(r => r.raw_target === 'u');
291
+ assert(!dyn, 'dynamic URL not recorded');
292
+
293
+ // readCache → apiClient.get(key) — NOT recorded (first arg is identifier)
294
+ const readCache = rows.find(r => r.raw_target === 'key');
295
+ assert(!readCache, 'identifier arg not recorded as service call');
296
+
297
+ // Caller attribution: list / create / checkout / fetchOrders / chargeCustomer
298
+ // must all be inside their enclosing functions, not module-level (NULL).
299
+ const callerCount = raw.prepare(
300
+ `SELECT COUNT(*) AS c FROM service_calls WHERE symbol_id IS NOT NULL`
301
+ ).get() as { c: number };
302
+ assert(callerCount.c >= 4, `most service calls have caller symbol (got ${callerCount.c})`);
303
+
304
+ // ── Python extraction ────────────────────────────────────────────────
305
+ console.log('\n── Step 3: Python HTTP client extraction ──');
306
+ type PyRow = {
307
+ framework: string; method: string | null; raw_target: string;
308
+ env_key: string | null;
309
+ };
310
+ const pyRows = raw.prepare(
311
+ `SELECT sc.framework, sc.method, sc.raw_target, sc.env_key
312
+ FROM service_calls sc JOIN files f ON f.id = sc.file_id
313
+ WHERE f.language = 'python' ORDER BY sc.line ASC`
314
+ ).all() as PyRow[];
315
+ console.log(` → ${pyRows.length} Python service_calls extracted`);
316
+
317
+ const reqGet = pyRows.find(r => r.framework === 'requests' && r.raw_target === '/health');
318
+ assert(!!reqGet, 'requests.get("/health") recorded');
319
+ if (reqGet) assertEq(reqGet.method, 'GET', 'method=GET for requests.get');
320
+
321
+ const reqPost = pyRows.find(r => r.framework === 'requests' && r.raw_target === '/api/users' && r.method === 'POST');
322
+ assert(!!reqPost, 'requests.post("/api/users") recorded with POST');
323
+
324
+ const httpxGet = pyRows.find(r => r.framework === 'httpx');
325
+ assert(!!httpxGet, 'httpx.get(…) recorded');
326
+
327
+ const generic = pyRows.find(r => r.framework === 'http-client' && r.raw_target === '/api/cart/items');
328
+ assert(!!generic, 'self.client.post("/api/cart/items") recorded as generic http-client');
329
+
330
+ const pyCharge = pyRows.find(r => r.raw_target === '/charge');
331
+ assert(!!pyCharge, 'binary concat "/charge" recovered');
332
+ if (pyCharge) assertEq(pyCharge.env_key, 'PAYMENT_URL', 'PAYMENT_URL envKey captured (Python)');
333
+
334
+ const pyDyn = pyRows.find(r => r.raw_target === 'url');
335
+ assert(!pyDyn, 'dynamic URL not recorded (Python)');
336
+
337
+ // ── Go extraction ────────────────────────────────────────────────────
338
+ console.log('\n── Step 3: Go HTTP client extraction ──');
339
+ type GoRow = { framework: string; method: string | null; raw_target: string };
340
+ const goRows = raw.prepare(
341
+ `SELECT sc.framework, sc.method, sc.raw_target
342
+ FROM service_calls sc JOIN files f ON f.id = sc.file_id
343
+ WHERE f.language = 'go' ORDER BY sc.line ASC`
344
+ ).all() as GoRow[];
345
+ console.log(` → ${goRows.length} Go service_calls extracted`);
346
+
347
+ const httpGet = goRows.find(r => r.framework === 'http' && r.method === 'GET' && r.raw_target === '/api/users');
348
+ assert(!!httpGet, 'http.Get("/api/users") recorded');
349
+ const httpPost = goRows.find(r => r.framework === 'http' && r.method === 'POST' && r.raw_target === '/api/users');
350
+ assert(!!httpPost, 'http.Post("/api/users", ...) recorded');
351
+ const clientGet = goRows.find(r => r.framework === 'http-client' && r.raw_target === '/api/orders');
352
+ assert(!!clientGet, 'client.Get("/api/orders") recorded as http-client');
353
+ const newReq = goRows.find(r => r.method === 'POST' && r.raw_target === '/api/items');
354
+ assert(!!newReq, 'http.NewRequest("POST", "/api/items", …) recorded with POST');
355
+
356
+ // ── Java extraction ──────────────────────────────────────────────────
357
+ console.log('\n── Step 3: Java HTTP client extraction ──');
358
+ type JRow = { framework: string; method: string | null; raw_target: string };
359
+ const jRows = raw.prepare(
360
+ `SELECT sc.framework, sc.method, sc.raw_target
361
+ FROM service_calls sc JOIN files f ON f.id = sc.file_id
362
+ WHERE f.language = 'java' ORDER BY sc.line ASC`
363
+ ).all() as JRow[];
364
+ console.log(` → ${jRows.length} Java service_calls extracted`);
365
+
366
+ const restGet = jRows.find(r => r.framework === 'spring-rest' && r.method === 'GET' && r.raw_target === '/api/users');
367
+ assert(!!restGet, 'restTemplate.getForObject("/api/users") recorded');
368
+ const restPost = jRows.find(r => r.framework === 'spring-rest' && r.method === 'POST' && r.raw_target === '/api/orders');
369
+ assert(!!restPost, 'restTemplate.postForObject("/api/orders", …) recorded');
370
+ const httpReq = jRows.find(r => r.framework === 'java.net.http' && r.raw_target === 'https://payment-service/api/ping');
371
+ assert(!!httpReq, 'HttpRequest.newBuilder(URI.create(...)) recorded');
372
+
373
+ // ── C# extraction ────────────────────────────────────────────────────
374
+ console.log('\n── Step 3: C# HTTP client extraction ──');
375
+ type CSRow = { framework: string; method: string | null; raw_target: string };
376
+ const csRows = raw.prepare(
377
+ `SELECT sc.framework, sc.method, sc.raw_target
378
+ FROM service_calls sc JOIN files f ON f.id = sc.file_id
379
+ WHERE f.language = 'csharp' ORDER BY sc.line ASC`
380
+ ).all() as CSRow[];
381
+ console.log(` → ${csRows.length} C# service_calls extracted`);
382
+
383
+ const csGet = csRows.find(r => r.method === 'GET' && r.raw_target === '/api/users');
384
+ assert(!!csGet, 'HttpClient.GetAsync("/api/users") recorded');
385
+ const csPost = csRows.find(r => r.method === 'POST' && r.raw_target === '/api/orders');
386
+ assert(!!csPost, 'HttpClient.PostAsJsonAsync("/api/orders", …) recorded');
387
+ const csDel = csRows.find(r => r.method === 'DELETE' && r.raw_target === 'https://auth/api/session');
388
+ assert(!!csDel, 'HttpClient.DeleteAsync absolute URL recorded');
389
+
390
+ // ── v9 Track-H Step 2: tRPC procedure + client extraction ──────────────
391
+ console.log('\n── Step 2 (Track-H): tRPC extraction ──');
392
+ type TrpcCallRow = {
393
+ framework: string; method: string | null; raw_target: string;
394
+ protocol: string; operation: string | null;
395
+ caller_qname: string | null;
396
+ };
397
+ const trpcCalls = raw.prepare(
398
+ `SELECT sc.framework, sc.method, sc.raw_target, sc.protocol, sc.operation,
399
+ s.qualified_name AS caller_qname
400
+ FROM service_calls sc
401
+ JOIN files f ON f.id = sc.file_id
402
+ LEFT JOIN symbols s ON s.id = sc.symbol_id
403
+ WHERE sc.protocol = 'trpc'
404
+ AND f.rel_path = 'trpc_client.ts'
405
+ ORDER BY sc.line ASC`
406
+ ).all() as TrpcCallRow[];
407
+ console.log(` → ${trpcCalls.length} tRPC client calls extracted`);
408
+
409
+ const trpcGet = trpcCalls.find(r => r.operation === 'user.getById' && r.method === 'QUERY' && r.framework === 'trpc-query');
410
+ assert(!!trpcGet, 'trpc.user.getById.query() recorded as trpc QUERY');
411
+ const trpcCreate = trpcCalls.find(r => r.operation === 'user.create' && r.method === 'MUTATION');
412
+ assert(!!trpcCreate, 'trpc.user.create.mutate() recorded as trpc MUTATION');
413
+ const trpcDel = trpcCalls.find(r => r.operation === 'user.delete' && r.method === 'MUTATION');
414
+ assert(!!trpcDel, 'trpc.user.delete.mutate() recorded');
415
+ const trpcUseQuery = trpcCalls.find(r => r.operation === 'user.getById' && r.framework === 'trpc-useQuery');
416
+ assert(!!trpcUseQuery, 'trpc.user.getById.useQuery() recorded as QUERY');
417
+ const trpcUseMut = trpcCalls.find(r => r.operation === 'user.create' && r.framework === 'trpc-useMutation');
418
+ assert(!!trpcUseMut, 'trpc.user.create.useMutation() recorded as MUTATION');
419
+ const apiCall = trpcCalls.find(r => r.operation === 'user.getById' && r.caller_qname === 'viaApi');
420
+ assert(!!apiCall, 'api.user.getById.query() recorded under viaApi caller');
421
+
422
+ // Negative case: random object.foo.bar.query() must NOT be a tRPC call.
423
+ const negCalls = raw.prepare(
424
+ `SELECT sc.operation FROM service_calls sc
425
+ JOIN files f ON f.id = sc.file_id
426
+ WHERE f.rel_path = 'trpc_client.ts' AND sc.protocol = 'trpc'
427
+ AND sc.operation = 'foo.bar'`
428
+ ).all() as Array<{ operation: string }>;
429
+ assertEq(negCalls.length, 0, 'someOtherObject.foo.bar.query() NOT a tRPC client call');
430
+
431
+ // Server-side: tRPC procedures land in routes with protocol='trpc'.
432
+ type TrpcRouteRow = {
433
+ method: string; path: string; framework: string;
434
+ protocol: string; operation: string | null;
435
+ handler_name: string | null; handler_qname: string | null;
436
+ };
437
+ const trpcRoutes = raw.prepare(
438
+ `SELECT r.method, r.path, r.framework, r.protocol, r.operation,
439
+ r.handler_name, s.qualified_name AS handler_qname
440
+ FROM routes r
441
+ JOIN files f ON f.id = r.file_id
442
+ LEFT JOIN symbols s ON s.id = r.handler_id
443
+ WHERE r.protocol = 'trpc' AND f.rel_path = 'trpc_server.ts'
444
+ ORDER BY r.id ASC`
445
+ ).all() as TrpcRouteRow[];
446
+ console.log(` → ${trpcRoutes.length} tRPC procedure routes extracted`);
447
+
448
+ const procGet = trpcRoutes.find(r => r.operation === 'getById' && r.method === 'QUERY');
449
+ assert(!!procGet, 'userRouter.getById procedure recorded as QUERY');
450
+ if (procGet) {
451
+ assertEq(procGet.framework, 'trpc', 'tRPC procedure framework = trpc');
452
+ assert(procGet.handler_name === 'getUserById' || procGet.handler_qname === 'getUserById',
453
+ 'getById handler resolves to getUserById');
454
+ }
455
+ const procCreate = trpcRoutes.find(r => r.operation === 'create' && r.method === 'MUTATION');
456
+ assert(!!procCreate, 'userRouter.create procedure recorded as MUTATION');
457
+ const procDelete = trpcRoutes.find(r => r.operation === 'delete' && r.method === 'MUTATION');
458
+ assert(!!procDelete, 'userRouter.delete procedure recorded (inline arrow handler)');
459
+
460
+ // tRPC → service_links: client trpc.user.getById.query() should link to
461
+ // userRouter.getById via last-segment match (server operation = 'getById').
462
+ type TrpcLinkRow = {
463
+ match_kind: string; protocol: string;
464
+ caller_qname: string | null; handler_qname: string | null;
465
+ route_operation: string | null; confidence: number;
466
+ };
467
+ const trpcLinks = raw.prepare(
468
+ `SELECT sl.match_kind, sl.protocol, sl.confidence,
469
+ sc.qualified_name AS caller_qname,
470
+ sh.qualified_name AS handler_qname,
471
+ r.operation AS route_operation
472
+ FROM service_links sl
473
+ LEFT JOIN symbols sc ON sc.id = sl.caller_symbol_id
474
+ LEFT JOIN symbols sh ON sh.id = sl.handler_symbol_id
475
+ LEFT JOIN routes r ON r.id = sl.route_id
476
+ WHERE sl.protocol = 'trpc'
477
+ ORDER BY sl.id ASC`
478
+ ).all() as TrpcLinkRow[];
479
+ console.log(` → ${trpcLinks.length} tRPC service_links resolved`);
480
+ assert(trpcLinks.length >= 3, `at least 3 tRPC links (getById/create/delete), got ${trpcLinks.length}`);
481
+
482
+ const getByIdLink = trpcLinks.find(l =>
483
+ l.caller_qname === 'fetchUser' && l.route_operation === 'getById');
484
+ assert(!!getByIdLink, 'fetchUser → userRouter.getById link present');
485
+ if (getByIdLink) {
486
+ assertEq(getByIdLink.match_kind, 'trpc_procedure', 'tRPC match_kind set');
487
+ assert(getByIdLink.handler_qname === 'getUserById',
488
+ `handler resolved to getUserById (got ${getByIdLink.handler_qname})`);
489
+ }
490
+
491
+ const createLink = trpcLinks.find(l =>
492
+ l.caller_qname === 'createUserClient' && l.route_operation === 'create');
493
+ assert(!!createLink, 'createUserClient → userRouter.create link present');
494
+
495
+ // Hook-based caller useUser → also resolves to getById.
496
+ const hookLink = trpcLinks.find(l =>
497
+ l.caller_qname === 'useUser' && l.route_operation === 'getById');
498
+ assert(!!hookLink, 'useUser → userRouter.getById (useQuery hook) link present');
499
+
500
+ // ── v9 Track-H Step 3: GraphQL resolver + client extraction ────────────
501
+ console.log('\n── Step 3 (Track-H): GraphQL extraction ──');
502
+ type GqlRouteRow = {
503
+ method: string; path: string; framework: string;
504
+ protocol: string; operation: string | null;
505
+ handler_name: string | null; handler_qname: string | null;
506
+ };
507
+ const gqlRoutes = raw.prepare(
508
+ `SELECT r.method, r.path, r.framework, r.protocol, r.operation,
509
+ r.handler_name, s.qualified_name AS handler_qname
510
+ FROM routes r
511
+ JOIN files f ON f.id = r.file_id
512
+ LEFT JOIN symbols s ON s.id = r.handler_id
513
+ WHERE r.protocol = 'graphql' AND f.rel_path = 'graphql_server.ts'
514
+ ORDER BY r.id ASC`
515
+ ).all() as GqlRouteRow[];
516
+ console.log(` → ${gqlRoutes.length} GraphQL resolver routes extracted`);
517
+
518
+ const userQ = gqlRoutes.find(r => r.operation === 'user' && r.method === 'QUERY');
519
+ assert(!!userQ, 'resolvers.Query.user → QUERY/user route emitted');
520
+ if (userQ) {
521
+ assertEq(userQ.framework, 'graphql', 'resolver framework = graphql');
522
+ assert(userQ.handler_name === 'userResolver' || userQ.handler_qname === 'userResolver',
523
+ `handler resolved to userResolver (got ${userQ.handler_name})`);
524
+ }
525
+ const usersQ = gqlRoutes.find(r => r.operation === 'users' && r.method === 'QUERY');
526
+ assert(!!usersQ, 'resolvers.Query.users → QUERY/users route emitted (inline arrow)');
527
+ const createM = gqlRoutes.find(r => r.operation === 'createUser' && r.method === 'MUTATION');
528
+ assert(!!createM, 'resolvers.Mutation.createUser → MUTATION/createUser route');
529
+ const deleteM = gqlRoutes.find(r => r.operation === 'deleteUser' && r.method === 'MUTATION');
530
+ assert(!!deleteM, 'resolvers.Mutation.deleteUser → MUTATION/deleteUser route');
531
+ const subS = gqlRoutes.find(r => r.operation === 'onUserCreated' && r.method === 'SUBSCRIPTION');
532
+ assert(!!subS, 'resolvers.Subscription.onUserCreated → SUBSCRIPTION/onUserCreated route');
533
+
534
+ // Client side
535
+ type GqlCallRow = {
536
+ framework: string; method: string | null;
537
+ protocol: string; operation: string | null;
538
+ caller_qname: string | null; metadata_json: string | null;
539
+ };
540
+ const gqlCalls = raw.prepare(
541
+ `SELECT sc.framework, sc.method, sc.protocol, sc.operation, sc.metadata_json,
542
+ s.qualified_name AS caller_qname
543
+ FROM service_calls sc
544
+ JOIN files f ON f.id = sc.file_id
545
+ LEFT JOIN symbols s ON s.id = sc.symbol_id
546
+ WHERE sc.protocol = 'graphql' AND f.rel_path = 'graphql_client.ts'
547
+ ORDER BY sc.line ASC`
548
+ ).all() as GqlCallRow[];
549
+ console.log(` → ${gqlCalls.length} GraphQL client calls extracted`);
550
+
551
+ // fetchUser → client.query({ query: GET_USER }) — operation stored as the
552
+ // document identifier; the resolver rewrites this to the parsed field
553
+ // name ('user') when matching against the resolver map.
554
+ const fetchUserCall = gqlCalls.find(c =>
555
+ c.caller_qname === 'fetchUser' && c.operation === 'GET_USER' && c.method === 'QUERY');
556
+ assert(!!fetchUserCall, 'fetchUser → client.query(GET_USER) recorded with operation=GET_USER');
557
+ if (fetchUserCall) {
558
+ assertEq(fetchUserCall.framework, 'graphql-query', 'framework = graphql-query');
559
+ }
560
+
561
+ // The gql-doc sentinel for GET_USER should also be present and parsed.
562
+ const gqlDoc = gqlCalls.find(c => c.framework === 'gql-doc' && c.operation === 'user');
563
+ assert(!!gqlDoc, 'gql-doc sentinel for GET_USER parsed (operation=user, the body field)');
564
+ if (gqlDoc) {
565
+ const meta = JSON.parse(gqlDoc.metadata_json ?? '{}');
566
+ assertEq(meta.operationName, 'GetUser', 'gql-doc metadata.operationName = GetUser');
567
+ assertEq(meta.documentIdent, 'GET_USER', 'gql-doc metadata.documentIdent = GET_USER');
568
+ }
569
+
570
+ // createUserViaApollo → apolloClient.mutate({ mutation: CREATE_USER })
571
+ const createApolloCall = gqlCalls.find(c =>
572
+ c.caller_qname === 'createUserViaApollo' && c.operation === 'CREATE_USER' && c.method === 'MUTATION');
573
+ assert(!!createApolloCall, 'createUserViaApollo → apolloClient.mutate(CREATE_USER) recorded');
574
+
575
+ // useUserHook → useQuery(gql`query ListAllUsers { users { ... } }`)
576
+ const useUserCall = gqlCalls.find(c =>
577
+ c.caller_qname === 'useUserHook' && c.operation === 'users');
578
+ assert(!!useUserCall, 'useUserHook → useQuery(inline gql) → operation=users');
579
+ if (useUserCall) assertEq(useUserCall.method, 'QUERY', 'useQuery emits QUERY');
580
+
581
+ // useCreateUserHook → useMutation(CREATE_USER) — operation = doc identifier
582
+ // since the const name is the only signal we have at this site (the gql
583
+ // body lives in another const definition).
584
+ const useCreateCall = gqlCalls.find(c =>
585
+ c.caller_qname === 'useCreateUserHook' && c.method === 'MUTATION');
586
+ assert(!!useCreateCall, 'useCreateUserHook → useMutation(CREATE_USER) recorded');
587
+ if (useCreateCall) {
588
+ assert(useCreateCall.operation === 'CREATE_USER' || useCreateCall.operation === 'createUser',
589
+ `operation = doc ident or field (got ${useCreateCall.operation})`);
590
+ }
591
+
592
+ // GraphQL service_links
593
+ type GqlLinkRow = {
594
+ match_kind: string; protocol: string; confidence: number;
595
+ caller_qname: string | null; handler_qname: string | null;
596
+ route_operation: string | null;
597
+ };
598
+ const gqlLinks = raw.prepare(
599
+ `SELECT sl.match_kind, sl.protocol, sl.confidence,
600
+ sc.qualified_name AS caller_qname,
601
+ sh.qualified_name AS handler_qname,
602
+ r.operation AS route_operation
603
+ FROM service_links sl
604
+ LEFT JOIN symbols sc ON sc.id = sl.caller_symbol_id
605
+ LEFT JOIN symbols sh ON sh.id = sl.handler_symbol_id
606
+ LEFT JOIN routes r ON r.id = sl.route_id
607
+ WHERE sl.protocol = 'graphql'
608
+ ORDER BY sl.id ASC`
609
+ ).all() as GqlLinkRow[];
610
+ console.log(` → ${gqlLinks.length} GraphQL service_links resolved`);
611
+ assert(gqlLinks.length >= 2, `at least 2 GraphQL links, got ${gqlLinks.length}`);
612
+
613
+ const fetchUserLink = gqlLinks.find(l =>
614
+ l.caller_qname === 'fetchUser' && l.route_operation === 'user');
615
+ assert(!!fetchUserLink, 'fetchUser → Query.user resolver link present');
616
+ if (fetchUserLink) {
617
+ assertEq(fetchUserLink.match_kind, 'graphql_operation', 'match_kind = graphql_operation');
618
+ assert(fetchUserLink.handler_qname === 'userResolver',
619
+ `handler resolved to userResolver (got ${fetchUserLink.handler_qname})`);
620
+ }
621
+ const createLnk = gqlLinks.find(l =>
622
+ l.caller_qname === 'createUserViaApollo' && l.route_operation === 'createUser');
623
+ assert(!!createLnk, 'createUserViaApollo → Mutation.createUser resolver link present');
624
+
625
+ // ── v9 Track-H Step 4: .proto + gRPC client extraction ─────────────────
626
+ console.log('\n── Step 4 (Track-H): gRPC extraction ──');
627
+ // Proto scanner emits one route per rpc, with operation = "Service/Method"
628
+ type GrpcRouteRow = {
629
+ method: string; path: string; framework: string;
630
+ protocol: string; operation: string | null; service: string | null;
631
+ handler_name: string | null;
632
+ };
633
+ const grpcRoutes = raw.prepare(
634
+ `SELECT r.method, r.path, r.framework, r.protocol, r.operation, r.service,
635
+ r.handler_name
636
+ FROM routes r
637
+ WHERE r.protocol = 'grpc'
638
+ ORDER BY r.id ASC`
639
+ ).all() as GrpcRouteRow[];
640
+ console.log(` → ${grpcRoutes.length} gRPC routes extracted from .proto`);
641
+ assertEq(grpcRoutes.length, 5,
642
+ 'all 5 rpc methods from user_service.proto extracted');
643
+
644
+ const protoGet = grpcRoutes.find(r => r.operation === 'UserService/GetUser');
645
+ assert(!!protoGet, 'UserService/GetUser route emitted');
646
+ if (protoGet) {
647
+ assertEq(protoGet.framework, 'grpc', 'framework = grpc');
648
+ assertEq(protoGet.service, 'UserService', 'service = UserService');
649
+ assertEq(protoGet.path, 'UserService/GetUser', 'path = canonical operation');
650
+ }
651
+ const protoCreate = grpcRoutes.find(r => r.operation === 'UserService/CreateUser');
652
+ assert(!!protoCreate, 'UserService/CreateUser route emitted (despite HTTP annotation)');
653
+ const protoLogin = grpcRoutes.find(r => r.operation === 'AuthService/Login');
654
+ assert(!!protoLogin, 'AuthService/Login route emitted');
655
+ const protoLogout = grpcRoutes.find(r => r.operation === 'AuthService/Logout');
656
+ assert(!!protoLogout, 'AuthService/Logout route emitted');
657
+
658
+ // The .proto file itself is indexed with language='proto'
659
+ const protoFile = raw.prepare(
660
+ `SELECT id, language FROM files WHERE rel_path = 'user_service.proto'`
661
+ ).get() as { id: number; language: string } | undefined;
662
+ assert(!!protoFile, '.proto file upserted into files table');
663
+ if (protoFile) assertEq(protoFile.language, 'proto', '.proto file language = proto');
664
+
665
+ const grpcRouteIdsBefore = raw.prepare(
666
+ `SELECT id, operation FROM routes WHERE protocol = 'grpc' ORDER BY operation ASC`
667
+ ).all() as Array<{ id: number; operation: string | null }>;
668
+ const cachedGrpc = await new Indexer(tsStore).indexDirectory(FIX_SERVICE, { quiet: true });
669
+ const protoFileAfter = raw.prepare(
670
+ `SELECT id, language FROM files WHERE rel_path = 'user_service.proto'`
671
+ ).get() as { id: number; language: string } | undefined;
672
+ const grpcRouteIdsAfter = raw.prepare(
673
+ `SELECT id, operation FROM routes WHERE protocol = 'grpc' ORDER BY operation ASC`
674
+ ).all() as Array<{ id: number; operation: string | null }>;
675
+ assertEq(cachedGrpc.pagerankRecomputed, false,
676
+ 'cached re-index with unchanged .proto skips PageRank');
677
+ if (protoFile && protoFileAfter) {
678
+ assertEq(protoFileAfter.id, protoFile.id,
679
+ 'cached re-index keeps .proto file row stable');
680
+ }
681
+ assertEq(JSON.stringify(grpcRouteIdsAfter), JSON.stringify(grpcRouteIdsBefore),
682
+ 'cached re-index keeps .proto gRPC route rows stable');
683
+
684
+ // Go client side
685
+ type GrpcCallRow = {
686
+ framework: string; method: string | null; raw_target: string;
687
+ protocol: string; operation: string | null; service: string | null;
688
+ caller_qname: string | null;
689
+ };
690
+ const grpcCalls = raw.prepare(
691
+ `SELECT sc.framework, sc.method, sc.raw_target, sc.protocol, sc.operation, sc.service,
692
+ s.qualified_name AS caller_qname
693
+ FROM service_calls sc
694
+ JOIN files f ON f.id = sc.file_id
695
+ LEFT JOIN symbols s ON s.id = sc.symbol_id
696
+ WHERE sc.protocol = 'grpc' AND f.rel_path = 'grpc_client.go'
697
+ ORDER BY sc.line ASC`
698
+ ).all() as GrpcCallRow[];
699
+ console.log(` → ${grpcCalls.length} gRPC client calls extracted (Go)`);
700
+
701
+ const getCall = grpcCalls.find(c =>
702
+ c.caller_qname === 'GetUserViaGrpc' && c.operation === 'UserService/GetUser');
703
+ assert(!!getCall, 'pb.NewUserServiceClient(...).GetUser → operation=UserService/GetUser');
704
+ if (getCall) {
705
+ assertEq(getCall.framework, 'grpc-go', 'framework = grpc-go');
706
+ assertEq(getCall.service, 'UserService', 'service captured');
707
+ }
708
+ const createCall = grpcCalls.find(c =>
709
+ c.caller_qname === 'CreateUserViaGrpc' && c.operation === 'UserService/CreateUser');
710
+ assert(!!createCall, 'pb.NewUserServiceClient(...).CreateUser recorded');
711
+ const loginCall = grpcCalls.find(c =>
712
+ c.caller_qname === 'LoginViaGrpc' && c.operation === 'AuthService/Login');
713
+ assert(!!loginCall, 'pb.NewAuthServiceClient(...).Login recorded');
714
+
715
+ // Unknown gRPC method: recorded as a call but no link.
716
+ const noSuchCall = grpcCalls.find(c => c.operation === 'UserService/NoSuchMethod');
717
+ assert(!!noSuchCall, 'NoSuchMethod is recorded even when unresolved');
718
+
719
+ // gRPC service_links: 3 known calls should link, NoSuchMethod should NOT.
720
+ type GrpcLinkRow = {
721
+ match_kind: string;
722
+ caller_qname: string | null; handler_qname: string | null;
723
+ route_operation: string | null; confidence: number;
724
+ };
725
+ const grpcLinks = raw.prepare(
726
+ `SELECT sl.match_kind, sl.confidence,
727
+ sc.qualified_name AS caller_qname,
728
+ sh.qualified_name AS handler_qname,
729
+ r.operation AS route_operation
730
+ FROM service_links sl
731
+ LEFT JOIN symbols sc ON sc.id = sl.caller_symbol_id
732
+ LEFT JOIN symbols sh ON sh.id = sl.handler_symbol_id
733
+ LEFT JOIN routes r ON r.id = sl.route_id
734
+ WHERE sl.protocol = 'grpc'
735
+ ORDER BY sl.id ASC`
736
+ ).all() as GrpcLinkRow[];
737
+ console.log(` → ${grpcLinks.length} gRPC service_links resolved`);
738
+ assertEq(grpcLinks.length, 3, '3 gRPC links: GetUser, CreateUser, Login');
739
+ for (const l of grpcLinks) {
740
+ assertEq(l.match_kind, 'grpc_method', 'gRPC match_kind = grpc_method');
741
+ }
742
+ const noSuchLink = grpcLinks.find(l =>
743
+ l.route_operation === 'UserService/NoSuchMethod' || l.caller_qname === 'CallUnknown');
744
+ assert(!noSuchLink, 'CallUnknown / NoSuchMethod did NOT link');
745
+
746
+ // ── v9 Track-H Step 5: Messaging (Kafka/SQS/SNS/Rabbit/NATS/Redis) ─────
747
+ console.log('\n── Step 5 (Track-H): Messaging extraction ──');
748
+ type MsgCallRow = {
749
+ protocol: string; framework: string; method: string | null;
750
+ topic: string | null; queue: string | null; exchange: string | null;
751
+ raw_target: string; caller_qname: string | null;
752
+ };
753
+ const msgCalls = raw.prepare(
754
+ `SELECT sc.protocol, sc.framework, sc.method, sc.topic, sc.queue, sc.exchange,
755
+ sc.raw_target, s.qualified_name AS caller_qname
756
+ FROM service_calls sc
757
+ JOIN files f ON f.id = sc.file_id
758
+ LEFT JOIN symbols s ON s.id = sc.symbol_id
759
+ WHERE f.rel_path = 'messaging.ts'
760
+ AND sc.protocol IN ('kafka','sqs','sns','rabbitmq','nats','redis_pubsub')
761
+ ORDER BY sc.line ASC`
762
+ ).all() as MsgCallRow[];
763
+ console.log(` → ${msgCalls.length} messaging producer calls extracted`);
764
+
765
+ // Kafka producers
766
+ const kafkaOrders = msgCalls.find(c =>
767
+ c.protocol === 'kafka' && c.topic === 'orders' && c.caller_qname === 'produceOrders');
768
+ assert(!!kafkaOrders, 'producer.send({topic:"orders"}) recorded');
769
+ if (kafkaOrders) assertEq(kafkaOrders.framework, 'kafkajs', 'framework=kafkajs');
770
+ const kafkaShip = msgCalls.find(c =>
771
+ c.protocol === 'kafka' && c.topic === 'shipments' && c.caller_qname === 'produceShipments');
772
+ assert(!!kafkaShip, 'producer.send({topic:"shipments"}) recorded');
773
+
774
+ // SQS
775
+ const sqsSend = msgCalls.find(c =>
776
+ c.protocol === 'sqs' && c.caller_qname === 'enqueueJob');
777
+ assert(!!sqsSend, 'sqs.sendMessage({QueueUrl:...}) recorded');
778
+ if (sqsSend) {
779
+ assertEq(sqsSend.queue, 'job-queue', 'SQS queue name extracted from URL');
780
+ assertEq(sqsSend.framework, 'aws-sdk-sqs', 'framework=aws-sdk-sqs');
781
+ }
782
+
783
+ // SNS
784
+ const snsPub = msgCalls.find(c =>
785
+ c.protocol === 'sns' && c.caller_qname === 'notifySubscribers');
786
+ assert(!!snsPub, 'sns.publish({TopicArn:...}) recorded');
787
+ if (snsPub) assertEq(snsPub.topic, 'user-events', 'SNS topic extracted from ARN');
788
+
789
+ // RabbitMQ
790
+ const rabbitPub = msgCalls.find(c =>
791
+ c.protocol === 'rabbitmq' && c.caller_qname === 'publishEvent');
792
+ assert(!!rabbitPub, 'channel.publish("exch","key",body) recorded');
793
+ if (rabbitPub) assertEq(rabbitPub.exchange, 'events', 'Rabbit exchange recorded');
794
+ const rabbitQ = msgCalls.find(c =>
795
+ c.protocol === 'rabbitmq' && c.caller_qname === 'pushToQ');
796
+ assert(!!rabbitQ, 'channel.sendToQueue("q",body) recorded');
797
+ if (rabbitQ) assertEq(rabbitQ.queue, 'mailer-queue', 'Rabbit queue recorded');
798
+
799
+ // NATS
800
+ const natsPub = msgCalls.find(c =>
801
+ c.protocol === 'nats' && c.caller_qname === 'natsPublish');
802
+ assert(!!natsPub, 'nc.publish("subject",data) recorded');
803
+ if (natsPub) assertEq(natsPub.topic, 'user.created', 'NATS subject recorded');
804
+
805
+ // Redis pub-sub
806
+ const redisP = msgCalls.find(c =>
807
+ c.protocol === 'redis_pubsub' && c.caller_qname === 'redisPub');
808
+ assert(!!redisP, 'redis.publish("chan",msg) recorded');
809
+
810
+ // Consumer routes
811
+ type MsgRouteRow = {
812
+ protocol: string; framework: string; method: string;
813
+ topic: string | null; queue: string | null;
814
+ path: string; handler_name: string | null;
815
+ };
816
+ const msgRoutes = raw.prepare(
817
+ `SELECT r.protocol, r.framework, r.method, r.topic, r.queue, r.path, r.handler_name
818
+ FROM routes r
819
+ JOIN files f ON f.id = r.file_id
820
+ WHERE f.rel_path = 'messaging.ts'
821
+ AND r.protocol IN ('kafka','sqs','sns','rabbitmq','nats','redis_pubsub')
822
+ ORDER BY r.id ASC`
823
+ ).all() as MsgRouteRow[];
824
+ console.log(` → ${msgRoutes.length} messaging consumer routes extracted`);
825
+
826
+ const kafkaCons = msgRoutes.find(r =>
827
+ r.protocol === 'kafka' && r.topic === 'orders');
828
+ assert(!!kafkaCons, 'consumer.subscribe({topic:"orders"}) → route emitted');
829
+ if (kafkaCons) assertEq(kafkaCons.method, 'CONSUME', 'consumer method = CONSUME');
830
+ const kafkaMulti = msgRoutes.filter(r =>
831
+ r.protocol === 'kafka' && (r.topic === 'shipments' || r.topic === 'invoices'));
832
+ assertEq(kafkaMulti.length, 2, 'topics array fans out to one route per topic');
833
+
834
+ const rabbitCons = msgRoutes.find(r =>
835
+ r.protocol === 'rabbitmq' && r.queue === 'mailer-queue');
836
+ assert(!!rabbitCons, 'channel.consume("mailer-queue",h) → route emitted');
837
+ if (rabbitCons) assert(rabbitCons.handler_name === 'rabbitHandler',
838
+ `consume handler captured (got ${rabbitCons.handler_name})`);
839
+
840
+ const natsCons = msgRoutes.find(r =>
841
+ r.protocol === 'nats' && r.topic === 'user.created');
842
+ assert(!!natsCons, 'nc.subscribe("user.created") → route emitted');
843
+
844
+ const sqsCons = msgRoutes.find(r =>
845
+ r.protocol === 'sqs' && r.queue === 'job-queue');
846
+ assert(!!sqsCons, 'sqs.receiveMessage({QueueUrl:...}) → route emitted');
847
+
848
+ // Messaging service_links: kafka orders pair, rabbit queue pair, SQS pair,
849
+ // NATS pair. Multiple consumers for same topic must produce multiple links.
850
+ type MsgLinkRow = {
851
+ protocol: string; match_kind: string;
852
+ caller_qname: string | null; handler_qname: string | null;
853
+ route_topic: string | null; route_queue: string | null;
854
+ };
855
+ const msgLinks = raw.prepare(
856
+ `SELECT sl.protocol, sl.match_kind,
857
+ sc.qualified_name AS caller_qname,
858
+ sh.qualified_name AS handler_qname,
859
+ r.topic AS route_topic, r.queue AS route_queue
860
+ FROM service_links sl
861
+ LEFT JOIN symbols sc ON sc.id = sl.caller_symbol_id
862
+ LEFT JOIN symbols sh ON sh.id = sl.handler_symbol_id
863
+ LEFT JOIN routes r ON r.id = sl.route_id
864
+ WHERE sl.protocol IN ('kafka','sqs','sns','rabbitmq','nats','redis_pubsub')
865
+ ORDER BY sl.id ASC`
866
+ ).all() as MsgLinkRow[];
867
+ console.log(` → ${msgLinks.length} messaging service_links resolved`);
868
+
869
+ const kafkaLnk = msgLinks.find(l =>
870
+ l.protocol === 'kafka' && l.route_topic === 'orders' && l.caller_qname === 'produceOrders');
871
+ assert(!!kafkaLnk, 'produceOrders → kafka orders consumer link');
872
+ if (kafkaLnk) assertEq(kafkaLnk.match_kind, 'topic_match', 'Kafka link match_kind = topic_match');
873
+
874
+ // Multi-consumer for shipments: produceShipments has only one consumer
875
+ // (subscribeMulti contains 'shipments'). Still asserts the route fanout
876
+ // worked.
877
+ const shipLnk = msgLinks.find(l =>
878
+ l.protocol === 'kafka' && l.route_topic === 'shipments' && l.caller_qname === 'produceShipments');
879
+ assert(!!shipLnk, 'produceShipments → shipments consumer link');
880
+
881
+ const sqsLnk = msgLinks.find(l =>
882
+ l.protocol === 'sqs' && l.route_queue === 'job-queue' && l.caller_qname === 'enqueueJob');
883
+ assert(!!sqsLnk, 'enqueueJob → SQS job-queue consumer link');
884
+ if (sqsLnk) assertEq(sqsLnk.match_kind, 'queue_match', 'SQS link match_kind = queue_match');
885
+
886
+ const rabbitLnk = msgLinks.find(l =>
887
+ l.protocol === 'rabbitmq' && l.route_queue === 'mailer-queue' && l.caller_qname === 'pushToQ');
888
+ assert(!!rabbitLnk, 'pushToQ → rabbitmq mailer-queue consumer link');
889
+
890
+ const natsLnk = msgLinks.find(l =>
891
+ l.protocol === 'nats' && l.route_topic === 'user.created');
892
+ assert(!!natsLnk, 'natsPublish → NATS user.created consumer link');
893
+
894
+ const redisLnk = msgLinks.find(l =>
895
+ l.protocol === 'redis_pubsub' && l.route_topic === 'chan:notifications');
896
+ assert(!!redisLnk, 'redisPub → redis_pubsub chan:notifications link');
897
+
898
+ // ── v9 Track-H Step 6: k8s / Docker service-host signal ────────────────
899
+ console.log('\n── Step 6 (Track-H): k8s/Docker service-host signal ──');
900
+ type SvcHostLinkRow = {
901
+ match_kind: string; confidence: number;
902
+ caller_qname: string | null; handler_qname: string | null;
903
+ host_hint: string | null; route_path: string | null;
904
+ };
905
+ const hostLinks = raw.prepare(
906
+ `SELECT sl.match_kind, sl.confidence,
907
+ sc.qualified_name AS caller_qname,
908
+ sh.qualified_name AS handler_qname,
909
+ cc.host_hint, r.path AS route_path
910
+ FROM service_links sl
911
+ LEFT JOIN service_calls cc ON cc.id = sl.call_id
912
+ LEFT JOIN symbols sc ON sc.id = sl.caller_symbol_id
913
+ LEFT JOIN symbols sh ON sh.id = sl.handler_symbol_id
914
+ LEFT JOIN routes r ON r.id = sl.route_id
915
+ WHERE sl.protocol = 'http' AND sl.match_kind = 'service_host'
916
+ ORDER BY sl.id ASC`
917
+ ).all() as SvcHostLinkRow[];
918
+ console.log(` → ${hostLinks.length} service_host-classified links`);
919
+
920
+ // paymentCall: payment-service is a known k8s host AND /api/charge is a
921
+ // workspace route → match_kind should be service_host.
922
+ const payLink = hostLinks.find(l => l.caller_qname === 'paymentCall');
923
+ assert(!!payLink, 'paymentCall → /api/charge linked as service_host');
924
+ if (payLink) {
925
+ assertEq(payLink.host_hint, 'payment-service', 'host_hint = payment-service');
926
+ assertEq(payLink.route_path, '/api/charge', 'route_path = /api/charge');
927
+ assert(payLink.confidence >= 0.95,
928
+ `confidence boosted past plain literal (got ${payLink.confidence})`);
929
+ }
930
+
931
+ // unknownHostCall: same path, different host (not in k8s/Docker) → should
932
+ // still link (literal_path) but NOT as service_host.
933
+ const allLinksToCharge = raw.prepare(
934
+ `SELECT sl.match_kind, sc.qualified_name AS caller_qname,
935
+ cc.host_hint
936
+ FROM service_links sl
937
+ LEFT JOIN service_calls cc ON cc.id = sl.call_id
938
+ LEFT JOIN symbols sc ON sc.id = sl.caller_symbol_id
939
+ LEFT JOIN routes r ON r.id = sl.route_id
940
+ WHERE r.path = '/api/charge'
941
+ ORDER BY sl.id ASC`
942
+ ).all() as Array<{ match_kind: string; caller_qname: string | null; host_hint: string | null }>;
943
+ const unknownHost = allLinksToCharge.find(l => l.caller_qname === 'unknownHostCall');
944
+ assert(!!unknownHost, 'unknownHostCall still links to /api/charge');
945
+ if (unknownHost) {
946
+ assert(unknownHost.match_kind !== 'service_host',
947
+ `unknown host NOT classified as service_host (got ${unknownHost.match_kind})`);
948
+ }
949
+
950
+ // hostOnlyCall: known host but no matching route — must NOT produce a link
951
+ // (host alone is not enough evidence).
952
+ const hostOnly = raw.prepare(
953
+ `SELECT COUNT(*) AS c FROM service_links sl
954
+ LEFT JOIN symbols sc ON sc.id = sl.caller_symbol_id
955
+ WHERE sc.qualified_name = 'hostOnlyCall'`
956
+ ).get() as { c: number };
957
+ assertEq(hostOnly.c, 0,
958
+ 'hostOnlyCall (known host + unknown path) did NOT link');
959
+ } finally { tsStore.close(); }
960
+
961
+ // ── v9 Track-H Step 8: service-aware graph tracing ──────────────────────
962
+ console.log('\n── Step 8 (Track-H): traceServiceDependencies + module variant ──');
963
+ {
964
+ const tDb = path.join(TMP_DIR, 'traceDeps.db');
965
+ const tStore = new Store(tDb);
966
+ try {
967
+ await new Indexer(tStore).indexDirectory(FIX_SERVICE, { quiet: true });
968
+ // Find processPayment caller and chargeHandler handler so we have a
969
+ // known caller→handler link in the fixture.
970
+ const procDef = tStore.getDefinition('processPayment');
971
+ assert(procDef.length > 0, 'processPayment symbol found');
972
+ const r = tStore.traceServiceDependencies(procDef[0].id, { maxDepth: 4, maxNodes: 50 });
973
+ assert(r.reached.length >= 1,
974
+ `processPayment reaches at least one handler via service-link (got ${r.reached.length})`);
975
+ const targetIds = new Set(r.reached.map(x => x.symbolId));
976
+ const chargeDef = tStore.getDefinition('chargeHandler');
977
+ assert(chargeDef.length > 0 && targetIds.has(chargeDef[0].id),
978
+ 'chargeHandler is in reached set');
979
+ const chargeHit = r.reached.find(x => x.symbolId === chargeDef[0].id)!;
980
+ assertEq(chargeHit.depth, 1, 'chargeHandler depth = 1');
981
+ assert(chargeHit.protocols.includes('http'),
982
+ `chargeHandler hop has http protocol (got ${JSON.stringify(chargeHit.protocols)})`);
983
+ assertEq(chargeHit.hops[0], procDef[0].id, 'hop chain starts at caller');
984
+ assertEq(chargeHit.hops[chargeHit.hops.length - 1], chargeDef[0].id, 'hop chain ends at handler');
985
+
986
+ // Bounded traversal: a tiny maxNodes cap must fire.
987
+ const capped = tStore.traceServiceDependencies(procDef[0].id, { maxDepth: 4, maxNodes: 0 });
988
+ assert(capped.reached.length <= 0 || capped.cutoff === 'maxNodes',
989
+ 'tiny maxNodes triggers cutoff');
990
+
991
+ // Depth cutoff: create a manual second service hop
992
+ // processPayment -> chargeHandler -> userResolver, then cap traversal at
993
+ // one hop. The first handler is returned, and cutoff must say maxDepth
994
+ // because more service-link graph exists beyond the boundary.
995
+ const userResolver = tStore.getDefinition('userResolver');
996
+ if (chargeDef.length > 0 && userResolver.length > 0) {
997
+ const fileRow = tStore.rawDb().prepare(
998
+ `SELECT id FROM files ORDER BY id ASC LIMIT 1`,
999
+ ).get() as { id: number };
1000
+ const call = tStore.rawDb().prepare(
1001
+ `INSERT INTO service_calls
1002
+ (file_id, symbol_id, protocol, method, raw_target, framework, line, confidence)
1003
+ VALUES (?, ?, 'http', 'GET', '/depth-only', 'manual', 0, 1.0)`,
1004
+ ).run(fileRow.id, chargeDef[0].id);
1005
+ tStore.rawDb().prepare(
1006
+ `INSERT INTO service_links
1007
+ (call_id, route_id, caller_symbol_id, handler_symbol_id, protocol, match_kind, confidence, evidence_json)
1008
+ VALUES (?, NULL, ?, ?, 'http', 'literal_path', 1.0, '{}')`,
1009
+ ).run(Number(call.lastInsertRowid), chargeDef[0].id, userResolver[0].id);
1010
+ const depthCapped = tStore.traceServiceDependencies(procDef[0].id, { maxDepth: 1, maxNodes: 50 });
1011
+ assertEq(depthCapped.cutoff, 'maxDepth',
1012
+ 'service dependency traversal reports maxDepth cutoff');
1013
+ }
1014
+
1015
+ // Module variant: even when all fixture files cluster into a single
1016
+ // module (so no cross-module links exist), the BFS must run cleanly
1017
+ // and report an empty reached set without crashing.
1018
+ const anyMod = tStore.rawDb().prepare(
1019
+ `SELECT id FROM modules ORDER BY id ASC LIMIT 1`,
1020
+ ).get() as { id: number } | undefined;
1021
+ if (anyMod) {
1022
+ const modR = tStore.traceModuleServiceDependencies(anyMod.id, { maxDepth: 3, maxNodes: 20 });
1023
+ assert(Array.isArray(modR.reached), 'traceModuleServiceDependencies returns an array');
1024
+ // When reached is non-empty, every entry must carry at least one protocol.
1025
+ assert(modR.reached.every(x => x.protocols.length > 0),
1026
+ 'every reached module reports protocols');
1027
+ } else {
1028
+ assert(false, 'at least one module exists after indexing fixtures');
1029
+ }
1030
+ } finally { tStore.close(); }
1031
+ }
1032
+
1033
+ // ── v9 Track-H Step 7: ambiguity cap + truncation telemetry ──────────────
1034
+ console.log('\n── Step 7 (Track-H): ambiguity & truncation ──');
1035
+ {
1036
+ const ambDb = path.join(TMP_DIR, 'ambiguity.db');
1037
+ const ambStore = new Store(ambDb);
1038
+ try {
1039
+ const r = ambStore.rawDb();
1040
+ // Seed one file, one symbol acting as caller, one service_call, and
1041
+ // many routes (100) all targeting /api/users. The resolver must cap
1042
+ // candidates and mark truncation.
1043
+ const fileId = Number(r.prepare(
1044
+ `INSERT INTO files(path, rel_path, language, hash, lines, indexed_at)
1045
+ VALUES (?, ?, ?, ?, ?, ?)`,
1046
+ ).run('/tmp/amb.ts', 'amb.ts', 'typescript', 'x', 1, Date.now()).lastInsertRowid);
1047
+ const symId = Number(r.prepare(
1048
+ `INSERT INTO symbols(name, qualified_name, kind, file_id, line_start, line_end, col_start, col_end, is_rankable, symbol_role)
1049
+ VALUES ('caller','caller','function',?,0,0,0,0,1,'definition')`,
1050
+ ).run(fileId).lastInsertRowid);
1051
+ r.prepare(
1052
+ `INSERT INTO service_calls
1053
+ (file_id, symbol_id, protocol, method, raw_target, normalized_path,
1054
+ host_hint, env_key, framework, line, confidence)
1055
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
1056
+ ).run(fileId, symId, 'http', 'GET', '/api/users', '/api/users',
1057
+ null, null, 'fetch', 1, 0.9);
1058
+ // 100 identical-path routes, ids 1..100 (after auto-increment).
1059
+ const ins = r.prepare(
1060
+ `INSERT INTO routes(file_id, method, path, framework, handler_name, line, protocol)
1061
+ VALUES (?,?,?,?,?,?,?)`,
1062
+ );
1063
+ for (let i = 0; i < 100; i++) {
1064
+ ins.run(fileId, 'GET', '/api/users', 'express', `h${i}`, i, 'http');
1065
+ }
1066
+ const { resolveServiceLinks } = await import('../src/indexer/serviceLinks');
1067
+ const result = resolveServiceLinks(ambStore);
1068
+ assertEq(result.linksInserted, 1, 'one link inserted despite 100 candidates');
1069
+ assertEq(result.truncated, 1, 'truncation telemetry reports 1 truncated call');
1070
+
1071
+ const link = r.prepare('SELECT * FROM service_links').get() as { evidence_json: string; route_id: number };
1072
+ const ev = JSON.parse(link.evidence_json);
1073
+ assertEq(ev.total_candidates, 100, 'evidence reports total_candidates=100');
1074
+ assertEq(ev.truncated, true, 'evidence.truncated = true');
1075
+ assertEq(ev.ambiguity_candidates.length, 5,
1076
+ `ambiguity capped at MAX_EVIDENCE_CANDIDATES = 5 (got ${ev.ambiguity_candidates.length})`);
1077
+ // Top pick is deterministic: lowest route_id wins on confidence tie.
1078
+ const minRouteId = r.prepare('SELECT MIN(id) AS m FROM routes').get() as { m: number };
1079
+ assertEq(link.route_id, minRouteId.m, 'top pick = route with lowest id (deterministic tie-break)');
1080
+
1081
+ // Re-running the resolver must produce the same top pick.
1082
+ resolveServiceLinks(ambStore);
1083
+ const linkAgain = r.prepare('SELECT route_id FROM service_links').get() as { route_id: number };
1084
+ assertEq(linkAgain.route_id, link.route_id, 'idempotent: same route picked on re-run');
1085
+ } finally { ambStore.close(); }
1086
+ }
1087
+
1088
+ // ── Step 5: post-index resolver produces service_links ────────────────
1089
+ console.log('\n── Step 5: service_links resolver ──');
1090
+ const linkDb = path.join(TMP_DIR, 'links.db');
1091
+ const linkStore = new Store(linkDb);
1092
+ const linkResult = await new Indexer(linkStore).indexDirectory(FIX_SERVICE, { quiet: true });
1093
+ try {
1094
+ const raw2 = linkStore.rawDb();
1095
+ type LinkRow = {
1096
+ call_id: number; route_id: number | null;
1097
+ caller_symbol_id: number | null; handler_symbol_id: number | null;
1098
+ match_kind: string; confidence: number; evidence_json: string;
1099
+ };
1100
+ const links = raw2.prepare(
1101
+ `SELECT call_id, route_id, caller_symbol_id, handler_symbol_id,
1102
+ match_kind, confidence, evidence_json
1103
+ FROM service_links ORDER BY id ASC`
1104
+ ).all() as LinkRow[];
1105
+ console.log(` → ${links.length} service_links resolved`);
1106
+
1107
+ assertEq(typeof linkResult.serviceLinks, 'number', 'IndexResult.serviceLinks present');
1108
+ assert((linkResult.serviceLinks ?? 0) >= 2, `at least 2 links (charge + getUser pattern), got ${linkResult.serviceLinks}`);
1109
+
1110
+ // Find the /api/charge link.
1111
+ type FullLink = LinkRow & {
1112
+ route_path: string; caller_qname: string | null; handler_qname: string | null;
1113
+ };
1114
+ const full = raw2.prepare(
1115
+ `SELECT sl.call_id, sl.route_id, sl.caller_symbol_id, sl.handler_symbol_id,
1116
+ sl.match_kind, sl.confidence, sl.evidence_json,
1117
+ r.path AS route_path,
1118
+ sc.qualified_name AS caller_qname,
1119
+ sh.qualified_name AS handler_qname
1120
+ FROM service_links sl
1121
+ LEFT JOIN routes r ON r.id = sl.route_id
1122
+ LEFT JOIN symbols sc ON sc.id = sl.caller_symbol_id
1123
+ LEFT JOIN symbols sh ON sh.id = sl.handler_symbol_id`
1124
+ ).all() as FullLink[];
1125
+
1126
+ const charge = full.find(l => l.route_path === '/api/charge');
1127
+ assert(!!charge, 'service_link found for /api/charge');
1128
+ if (charge) {
1129
+ assertEq(charge.match_kind, 'literal_path', '/api/charge matched literal_path');
1130
+ assert(charge.confidence >= 0.9, `confidence ≥ 0.9 (got ${charge.confidence})`);
1131
+ assertEq(charge.caller_qname, 'processPayment', 'caller is processPayment');
1132
+ assertEq(charge.handler_qname, 'chargeHandler', 'handler is chargeHandler');
1133
+ }
1134
+
1135
+ // The /users/:id pattern should be matched by fetch('/users/123').
1136
+ const pattern = full.find(l => l.match_kind === 'route_pattern');
1137
+ assert(!!pattern, 'a route_pattern link was produced');
1138
+ if (pattern) {
1139
+ assert(pattern.confidence >= 0.7 && pattern.confidence <= 0.95,
1140
+ `pattern confidence in (0.7, 0.95] (got ${pattern.confidence})`);
1141
+ }
1142
+
1143
+ // Idempotency: re-running the resolver should produce the same row count.
1144
+ const r2 = await new Indexer(linkStore).indexDirectory(FIX_SERVICE, { quiet: true });
1145
+ const after = raw2.prepare('SELECT COUNT(*) AS c FROM service_links').get() as { c: number };
1146
+ assertEq(after.c, linkResult.serviceLinks ?? 0, 'cached re-index keeps link count stable');
1147
+ assert((r2.serviceLinks ?? 0) === (linkResult.serviceLinks ?? 0), 'resolver is idempotent');
1148
+
1149
+ // File deletion prunes links.
1150
+ const gatewayFileId = raw2.prepare(
1151
+ `SELECT id FROM files WHERE rel_path = 'gateway.ts'`
1152
+ ).get() as { id: number } | undefined;
1153
+ if (gatewayFileId) {
1154
+ raw2.prepare('DELETE FROM files WHERE id = ?').run(gatewayFileId.id);
1155
+ const remaining = raw2.prepare(
1156
+ `SELECT COUNT(*) AS c FROM service_calls`
1157
+ ).get() as { c: number };
1158
+ assert(remaining.c < (linkResult.symbols ?? 9999),
1159
+ 'service_calls reduced after file delete (cascade)');
1160
+ // Re-run resolver from outside: rebuild via the indexer's finishIndex.
1161
+ // The new index pass will see the gateway file as removed and resolve
1162
+ // service links over remaining rows.
1163
+ }
1164
+ } finally { linkStore.close(); }
1165
+
1166
+ // ── Step 6: Store APIs ────────────────────────────────────────────────
1167
+ console.log('\n── Step 6: Store APIs ──');
1168
+ const apiDb = path.join(TMP_DIR, 'api.db');
1169
+ const apiStore = new Store(apiDb);
1170
+ await new Indexer(apiStore).indexDirectory(FIX_SERVICE, { quiet: true });
1171
+ try {
1172
+ const c = apiStore.countServiceCalls();
1173
+ const l = apiStore.countServiceLinks();
1174
+ assert(c > 0, `countServiceCalls > 0 (got ${c})`);
1175
+ assert(l > 0, `countServiceLinks > 0 (got ${l})`);
1176
+
1177
+ const sc = apiStore.listServiceCalls({ limit: 50 });
1178
+ assert(sc.length > 0, `listServiceCalls returns rows (got ${sc.length})`);
1179
+ assert(sc.length <= 50, 'listServiceCalls respects limit');
1180
+ const fetched = apiStore.listServiceCalls({ framework: 'fetch' });
1181
+ assert(fetched.every(r => r.framework === 'fetch'), 'framework filter applied');
1182
+ const post = apiStore.listServiceCalls({ method: 'POST' });
1183
+ assert(post.every(r => r.method === 'POST'), 'method filter applied');
1184
+
1185
+ // Pagination
1186
+ const page1 = apiStore.listServiceCalls({ limit: 2, offset: 0 });
1187
+ const page2 = apiStore.listServiceCalls({ limit: 2, offset: 2 });
1188
+ if (page1.length === 2 && page2.length > 0) {
1189
+ assert(page1[0].id !== page2[0].id, 'offset paginates distinct rows');
1190
+ }
1191
+
1192
+ // Service links
1193
+ const links = apiStore.listServiceLinks();
1194
+ assert(links.length === l, 'listServiceLinks returns countServiceLinks rows');
1195
+ const chargeLink = links.find(x => x.routePath === '/api/charge');
1196
+ assert(!!chargeLink, 'service_link to /api/charge present');
1197
+ if (chargeLink) {
1198
+ assertEq(chargeLink.callerQualifiedName, 'processPayment', 'caller resolved');
1199
+ assertEq(chargeLink.handlerQualifiedName, 'chargeHandler', 'handler resolved');
1200
+ assertEq(chargeLink.routeMethod, 'POST', 'route method = POST');
1201
+ }
1202
+
1203
+ // id-scoped helpers — find processPayment's symbol id then query for it
1204
+ const sym = apiStore.rawDb().prepare(
1205
+ `SELECT id FROM symbols WHERE qualified_name = 'processPayment' LIMIT 1`
1206
+ ).get() as { id: number } | undefined;
1207
+ assert(!!sym, 'found processPayment symbol');
1208
+ if (sym) {
1209
+ const callerLinks = apiStore.serviceLinksForCaller(sym.id);
1210
+ assert(callerLinks.length >= 1, 'serviceLinksForCaller returns rows');
1211
+ assert(callerLinks.every(x => x.callerSymbolId === sym.id),
1212
+ 'serviceLinksForCaller is id-scoped');
1213
+ }
1214
+ const hsym = apiStore.rawDb().prepare(
1215
+ `SELECT id FROM symbols WHERE qualified_name = 'chargeHandler' LIMIT 1`
1216
+ ).get() as { id: number } | undefined;
1217
+ if (hsym) {
1218
+ const handlerLinks = apiStore.serviceLinksForHandler(hsym.id);
1219
+ assert(handlerLinks.length >= 1, 'serviceLinksForHandler returns rows');
1220
+ assert(handlerLinks.every(x => x.handlerSymbolId === hsym.id),
1221
+ 'serviceLinksForHandler is id-scoped');
1222
+ }
1223
+
1224
+ // traceServicePath: processPayment → chargeHandler should be 1 hop.
1225
+ if (sym && hsym) {
1226
+ const path = apiStore.traceServicePath(sym.id, hsym.id, 4);
1227
+ assertEq(path.length, 2, 'traceServicePath finds 2-node path (caller, handler)');
1228
+ if (path.length === 2) {
1229
+ assertEq(path[0], sym.id, 'path starts at caller');
1230
+ assertEq(path[1], hsym.id, 'path ends at handler');
1231
+ }
1232
+ }
1233
+ } finally { apiStore.close(); }
1234
+
1235
+ // ── Step 4: URL normalization + route pattern matcher ────────────────
1236
+ console.log('\n── Step 4: URL normalization ──');
1237
+ let r = normalizeHttpTarget('/api/users');
1238
+ assertEq(r.path, '/api/users', 'plain path passes through');
1239
+ assert(!r.hostHint && !r.envKey, 'no host/env recovered from plain path');
1240
+
1241
+ r = normalizeHttpTarget('https://payment-service/api/charge');
1242
+ assertEq(r.path, '/api/charge', 'scheme + host stripped from absolute URL');
1243
+ assertEq(r.hostHint, 'payment-service', 'host recovered from absolute URL');
1244
+
1245
+ r = normalizeHttpTarget('/api/items?q=hi&page=2');
1246
+ assertEq(r.path, '/api/items', 'query string dropped');
1247
+
1248
+ r = normalizeHttpTarget('/api/items#section');
1249
+ assertEq(r.path, '/api/items', 'fragment dropped');
1250
+
1251
+ r = normalizeHttpTarget('/api/users/');
1252
+ assertEq(r.path, '/api/users', 'trailing slash normalized');
1253
+
1254
+ r = normalizeHttpTarget('/');
1255
+ assertEq(r.path, '/', 'root path preserved');
1256
+
1257
+ r = normalizeHttpTarget('bare-text');
1258
+ assertEq(r.path, undefined, 'bare identifier rejected');
1259
+
1260
+ // Route patterns
1261
+ console.log('\n── Step 4: Route-pattern matcher ──');
1262
+ let m = routePatternsMatch('/users/123', '/users/:id');
1263
+ assert(m.matched && m.confidence >= 0.8, 'Express :id matches /users/123');
1264
+ m = routePatternsMatch('/users/123', '/users/{id}');
1265
+ assert(m.matched && m.confidence >= 0.8, 'FastAPI/Spring {id} matches /users/123');
1266
+ m = routePatternsMatch('/users/123', '/users/<int:id>');
1267
+ assert(m.matched, 'Flask <int:id> matches /users/123');
1268
+ m = routePatternsMatch('/users/123/posts/9', '/users/:id/posts/:pid');
1269
+ assert(m.matched, 'nested params match');
1270
+ m = routePatternsMatch('/users/123', '/users');
1271
+ assert(!m.matched, 'segment count mismatch rejected');
1272
+ m = routePatternsMatch('/users/123', '/posts/:id');
1273
+ assert(!m.matched, 'literal-segment mismatch rejected');
1274
+ m = routePatternsMatch('/users/123', '/users/123');
1275
+ assert(m.matched && m.confidence === 0.95, 'exact literal match is 0.95');
1276
+
1277
+ // Method match score
1278
+ console.log('\n── Step 4: Method comparator ──');
1279
+ assertEq(methodMatchScore('GET', 'GET'), 1.0, 'GET vs GET = 1.0');
1280
+ assertEq(methodMatchScore('ANY', 'GET'), 0.9, 'ANY vs GET = 0.9');
1281
+ assertEq(methodMatchScore(null, 'GET'), 0.9, 'null vs GET = 0.9 (null treated as ANY)');
1282
+ assertEq(methodMatchScore('POST', 'GET'), 0, 'POST vs GET = 0');
1283
+
1284
+ // ── Step 8: risk + context surface service-link signals ─────────────
1285
+ console.log('\n── Step 8: risk + context integration ──');
1286
+ const intDb = path.join(TMP_DIR, 'integrate.db');
1287
+ const intStore = new Store(intDb);
1288
+ await new Indexer(intStore).indexDirectory(FIX_SERVICE, { quiet: true });
1289
+ try {
1290
+ // Caller side (processPayment) → should have outboundServiceCalls + serviceLinksOutbound.
1291
+ const ctxCaller = buildContext(intStore, 'processPayment');
1292
+ assert(!!ctxCaller, 'context found for processPayment');
1293
+ if (ctxCaller) {
1294
+ assert(ctxCaller.serviceCalls.length >= 1,
1295
+ `processPayment has serviceCalls (got ${ctxCaller.serviceCalls.length})`);
1296
+ assert(ctxCaller.serviceLinksOutbound.length >= 1,
1297
+ `processPayment has serviceLinksOutbound (got ${ctxCaller.serviceLinksOutbound.length})`);
1298
+ const charge = ctxCaller.serviceCalls.find(c => c.path === '/api/charge');
1299
+ assert(!!charge, 'context.serviceCalls includes /api/charge');
1300
+ }
1301
+
1302
+ // Handler side (chargeHandler) → should have serviceLinksInbound.
1303
+ const ctxHandler = buildContext(intStore, 'chargeHandler');
1304
+ assert(!!ctxHandler, 'context found for chargeHandler');
1305
+ if (ctxHandler) {
1306
+ assert(ctxHandler.serviceLinksInbound.length >= 1,
1307
+ `chargeHandler has serviceLinksInbound (got ${ctxHandler.serviceLinksInbound.length})`);
1308
+ }
1309
+
1310
+ // Risk signals.
1311
+ const riskCaller = computeRisk(intStore, 'processPayment');
1312
+ assert(!!riskCaller, 'risk computed for processPayment');
1313
+ if (riskCaller) {
1314
+ assert(riskCaller.signals.outboundServiceCalls >= 1,
1315
+ `risk.outboundServiceCalls ≥ 1 (got ${riskCaller.signals.outboundServiceCalls})`);
1316
+ const cont = riskCaller.signalContributions.find(c => c.signal === 'outboundServiceCalls');
1317
+ assert(!!cont, 'outboundServiceCalls contribution present');
1318
+ if (cont) assert(cont.contribution > 0, 'outboundServiceCalls contributes to risk');
1319
+ }
1320
+
1321
+ const riskHandler = computeRisk(intStore, 'chargeHandler');
1322
+ assert(!!riskHandler, 'risk computed for chargeHandler');
1323
+ if (riskHandler) {
1324
+ assert(riskHandler.signals.inboundServiceLinks >= 1,
1325
+ `risk.inboundServiceLinks ≥ 1 (got ${riskHandler.signals.inboundServiceLinks})`);
1326
+ }
1327
+
1328
+ // Module integration: service links should produce a 'service' kind edge.
1329
+ const moduleServiceEdges = intStore.rawDb().prepare(
1330
+ `SELECT COUNT(*) AS c FROM module_edges WHERE kind = 'service'`
1331
+ ).get() as { c: number };
1332
+ assert(moduleServiceEdges.c >= 0, 'module_edges accepts service kind');
1333
+ } finally { intStore.close(); }
1334
+
1335
+ // ── Step 7 (CLI smoke): seer service-calls / service-links / trace-service
1336
+ console.log('\n── Step 7: CLI smoke ──');
1337
+ {
1338
+ const child = require('child_process');
1339
+ const ROOT = path.join(__dirname, '..');
1340
+ const CLI = path.join(ROOT, 'dist/cli/index.js');
1341
+ const cliDb = path.join(TMP_DIR, 'cli.db');
1342
+ // Pre-index for the CLI commands to query.
1343
+ const s = new Store(cliDb);
1344
+ await new Indexer(s).indexDirectory(FIX_SERVICE, { quiet: true });
1345
+ s.close();
1346
+
1347
+ const run = (args: string[]): string => child.execFileSync(
1348
+ process.execPath, [CLI, ...args, '--db', cliDb],
1349
+ { encoding: 'utf8' });
1350
+
1351
+ const sc = run(['service-calls', '-n', '50']);
1352
+ assert(sc.includes('Service calls'), 'service-calls CLI prints rows');
1353
+ assert(sc.includes('/api/charge'), 'service-calls lists /api/charge');
1354
+
1355
+ const sl = run(['service-links', '-n', '50']);
1356
+ assert(sl.includes('Service links'), 'service-links CLI prints rows');
1357
+ assert(sl.includes('processPayment'), 'service-links lists processPayment caller');
1358
+ assert(sl.includes('chargeHandler'), 'service-links lists chargeHandler handler');
1359
+
1360
+ const trace = run(['trace-service', 'processPayment', 'chargeHandler']);
1361
+ assert(trace.includes('Service path'), 'trace-service finds a path');
1362
+ assert(trace.includes('processPayment'), 'trace-service path starts at caller');
1363
+ assert(trace.includes('chargeHandler'), 'trace-service path ends at handler');
1364
+ }
1365
+
1366
+ try { fs.rmSync(TMP_DIR, { recursive: true, force: true }); } catch { /* */ }
1367
+ console.log(`\n══════════════════════════════════════════════════════════════`);
1368
+ console.log(` Track G Step 1+3+4+5+6+7: ${passed} passed, ${failed} failed`);
1369
+ if (failed > 0) process.exit(1);
1370
+ }
1371
+
1372
+ main().catch(err => { console.error('trackg crashed:', err); process.exit(1); });