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,453 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { spawn, spawnSync } from 'child_process';
4
+
5
+ /**
6
+ * Thin async wrapper around `git` so the rest of the indexer doesn't deal
7
+ * with child_process directly. All commands run with `cwd = repoRoot`.
8
+ *
9
+ * Errors are surfaced as `null` returns (not throws) so a non-git workspace
10
+ * silently no-ops. Callers should check the return value.
11
+ */
12
+
13
+ export function isGitRepo(repoRoot: string): boolean {
14
+ try {
15
+ const r = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' });
16
+ return r.status === 0 && r.stdout.trim() === 'true';
17
+ } catch { return false; }
18
+ }
19
+
20
+ export function gitHeadSha(repoRoot: string): string | null {
21
+ try {
22
+ const r = spawnSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
23
+ if (r.status !== 0) return null;
24
+ return r.stdout.trim() || null;
25
+ } catch { return null; }
26
+ }
27
+
28
+ export function gitRemoteUrl(repoRoot: string, remote = 'origin'): string | null {
29
+ try {
30
+ const r = spawnSync('git', ['-C', repoRoot, 'config', '--get', `remote.${remote}.url`], { encoding: 'utf8' });
31
+ if (r.status !== 0) return null;
32
+ return r.stdout.trim() || null;
33
+ } catch { return null; }
34
+ }
35
+
36
+ export interface FileChurnStats {
37
+ commitCount: number;
38
+ lastCommitSha: string | null;
39
+ lastCommitAt: number | null; // unix seconds
40
+ topAuthor: string | null;
41
+ secondAuthor: string | null;
42
+ }
43
+
44
+ /**
45
+ * Collect file-level churn stats by streaming `git log` once and bucketing
46
+ * per file. Uses `--follow` per-file would be ~slow on big repos, so we use
47
+ * a single `git log --name-only` pass and aggregate in JS. The trade-off is
48
+ * that renames lose their pre-rename history; callers documenting that fact
49
+ * matches the master guide's "honest about rename limits" stance.
50
+ */
51
+ export async function collectFileChurn(
52
+ repoRoot: string,
53
+ filesAbs: Iterable<string>,
54
+ ): Promise<Map<string, FileChurnStats>> {
55
+ const result = new Map<string, FileChurnStats>();
56
+ if (!isGitRepo(repoRoot)) return result;
57
+
58
+ // Build a quick lookup: relPath (forward slashes, normalized) → absPath.
59
+ // `git log` reports paths relative to repo root, so we have to translate
60
+ // back to absolute paths the indexer keyed off.
61
+ const absSet = new Set<string>();
62
+ for (const a of filesAbs) absSet.add(normalize(a));
63
+
64
+ return new Promise((resolve, reject) => {
65
+ const proc = spawn(
66
+ 'git',
67
+ ['-C', repoRoot, 'log', '--name-only', '--pretty=format:__COMMIT__%H%x09%an%x09%aI', '--no-merges'],
68
+ { stdio: ['ignore', 'pipe', 'pipe'] },
69
+ );
70
+ let buf = '';
71
+ let currentSha: string | null = null;
72
+ let currentAuthor: string | null = null;
73
+ let currentDateSec: number | null = null;
74
+
75
+ const perFile = new Map<string, {
76
+ count: number;
77
+ lastSha: string | null;
78
+ lastAt: number | null;
79
+ authors: Map<string, number>;
80
+ }>();
81
+
82
+ const handleLine = (line: string): void => {
83
+ if (line.startsWith('__COMMIT__')) {
84
+ const parts = line.slice('__COMMIT__'.length).split('\t');
85
+ currentSha = parts[0] || null;
86
+ currentAuthor = parts[1] || null;
87
+ currentDateSec = parts[2] ? Math.floor(Date.parse(parts[2]) / 1000) : null;
88
+ return;
89
+ }
90
+ if (!line.trim()) return;
91
+ const rel = normalize(line);
92
+ // Resolve to absolute. Path may use forward slashes; we compare against
93
+ // absSet directly. Also fall back to repoRoot-joined.
94
+ const cand1 = normalize(path.resolve(repoRoot, rel));
95
+ let key: string | null = null;
96
+ if (absSet.has(cand1)) key = cand1;
97
+ else if (absSet.has(rel)) key = rel;
98
+ if (!key) return;
99
+ let entry = perFile.get(key);
100
+ if (!entry) {
101
+ entry = { count: 0, lastSha: null, lastAt: null, authors: new Map() };
102
+ perFile.set(key, entry);
103
+ }
104
+ entry.count++;
105
+ if (entry.lastSha === null) {
106
+ entry.lastSha = currentSha;
107
+ entry.lastAt = currentDateSec;
108
+ }
109
+ if (currentAuthor) {
110
+ entry.authors.set(currentAuthor, (entry.authors.get(currentAuthor) ?? 0) + 1);
111
+ }
112
+ };
113
+
114
+ proc.stdout.on('data', (chunk: Buffer) => {
115
+ buf += chunk.toString('utf8');
116
+ let nl: number;
117
+ while ((nl = buf.indexOf('\n')) >= 0) {
118
+ const line = buf.slice(0, nl);
119
+ buf = buf.slice(nl + 1);
120
+ handleLine(line);
121
+ }
122
+ });
123
+ proc.stderr.on('data', () => { /* swallow */ });
124
+ proc.on('error', reject);
125
+ proc.on('close', () => {
126
+ if (buf.length > 0) handleLine(buf);
127
+ for (const [key, e] of perFile) {
128
+ const sortedAuthors = Array.from(e.authors.entries()).sort((a, b) => b[1] - a[1]);
129
+ result.set(key, {
130
+ commitCount: e.count,
131
+ lastCommitSha: e.lastSha,
132
+ lastCommitAt: e.lastAt,
133
+ topAuthor: sortedAuthors[0]?.[0] ?? null,
134
+ secondAuthor: sortedAuthors[1]?.[0] ?? null,
135
+ });
136
+ }
137
+ resolve(result);
138
+ });
139
+ });
140
+ }
141
+
142
+ export interface CommitMeta {
143
+ sha: string;
144
+ authorName: string | null;
145
+ authorEmail: string | null;
146
+ committedAt: number; // unix seconds
147
+ message: string;
148
+ /**
149
+ * The path the file had AT THIS COMMIT (forward-slash, repo-relative).
150
+ * Resolved from `git log --follow --name-status` so commits prior to a
151
+ * rename can still be looked up by the historical path. Null if the path
152
+ * couldn't be determined from log output (in which case callers should
153
+ * fall back to the current path).
154
+ */
155
+ pathAtCommit: string | null;
156
+ }
157
+
158
+ /**
159
+ * `git log` commits that touched a single file, newest first. Each commit
160
+ * includes its author info and full message. Used by the symbol-history pass.
161
+ *
162
+ * `--name-status` is added on top of `--follow` so we get a per-commit path —
163
+ * critical for the rename case: the historical commits touched the OLD path,
164
+ * but our usual `git show <sha> -- <currentPath>` would look at the wrong
165
+ * path and return empty hunks. With pathAtCommit threaded through to
166
+ * fileDiffInfo(), pre-rename history is preserved.
167
+ *
168
+ * Parser shape: we ask git for a per-commit header line prefixed `__C__`
169
+ * (sha, author, email, ISO date — all NUL-free fields tab-separated), then
170
+ * the commit body (terminated by a unique end marker `__BODY_END__`), then
171
+ * the name-status lines that git appends after each commit. The name-status
172
+ * block has one path entry per commit relative to this file's --follow chain:
173
+ * either `M\tpath`, `A\tpath`, `D\tpath`, or `R<score>\toldPath\tnewPath`.
174
+ *
175
+ * Using a custom body terminator (instead of git's default blank-line
176
+ * separator) lets us handle commit messages that themselves contain blank
177
+ * lines or `__C__` literals without re-introducing the old `__C__ff8` bug.
178
+ */
179
+ export async function commitsForFile(
180
+ repoRoot: string,
181
+ filePath: string,
182
+ options: { limit?: number; since?: number } = {},
183
+ ): Promise<CommitMeta[]> {
184
+ if (!isGitRepo(repoRoot)) return [];
185
+ const rel = path.relative(repoRoot, filePath);
186
+ const args = ['-C', repoRoot, 'log',
187
+ '--pretty=format:__C__%H%x09%an%x09%ae%x09%aI%n%B%n__BODY_END__',
188
+ '--no-merges',
189
+ '--follow',
190
+ '--name-status',
191
+ ];
192
+ if (options.limit) args.push(`-n${options.limit}`);
193
+ if (options.since) args.push(`--since=${new Date(options.since * 1000).toISOString()}`);
194
+ args.push('--', rel);
195
+
196
+ return new Promise((resolve) => {
197
+ const proc = spawn('git', args, { stdio: ['ignore', 'pipe', 'pipe'] });
198
+ let buf = '';
199
+ proc.stdout.on('data', (c: Buffer) => { buf += c.toString('utf8'); });
200
+ proc.stderr.on('data', () => { /* */ });
201
+ proc.on('error', () => resolve([]));
202
+ proc.on('close', () => {
203
+ resolve(parseFollowLog(buf));
204
+ });
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Walk the line stream from `git log --follow --name-status` (with the
210
+ * `__C__` header / `__BODY_END__` body terminator format above) and emit
211
+ * one CommitMeta per `__C__` header. Exposed only for tests; the live
212
+ * pipeline goes through commitsForFile().
213
+ */
214
+ export function parseFollowLog(buf: string): CommitMeta[] {
215
+ const out: CommitMeta[] = [];
216
+ // Normalize CRLF that Windows git may inject in some setups so the line
217
+ // walker doesn't end up with trailing \r in messages or paths.
218
+ const lines = buf.replace(/\r\n/g, '\n').split('\n');
219
+ let i = 0;
220
+ while (i < lines.length) {
221
+ const line = lines[i];
222
+ if (!line.startsWith('__C__')) { i++; continue; }
223
+ const headerParts = line.slice('__C__'.length).split('\t');
224
+ if (headerParts.length < 4) { i++; continue; }
225
+ const [sha, author, email, dateStr] = headerParts;
226
+ const committedAt = Math.floor(Date.parse(dateStr) / 1000);
227
+ if (!sha || isNaN(committedAt)) { i++; continue; }
228
+ // Collect message body until __BODY_END__.
229
+ i++;
230
+ const msgLines: string[] = [];
231
+ while (i < lines.length && lines[i] !== '__BODY_END__') {
232
+ msgLines.push(lines[i]);
233
+ i++;
234
+ }
235
+ // Skip the __BODY_END__ line itself.
236
+ if (i < lines.length) i++;
237
+ // Collect name-status lines until the next __C__ header or EOF. Blank
238
+ // lines (which git emits between commit body and name-status) are
239
+ // skipped; we never have to interpret them.
240
+ let pathAtCommit: string | null = null;
241
+ while (i < lines.length && !lines[i].startsWith('__C__')) {
242
+ const nl = lines[i];
243
+ if (nl.length === 0) { i++; continue; }
244
+ const fields = nl.split('\t');
245
+ if (fields.length >= 2) {
246
+ const code = fields[0];
247
+ if (code.startsWith('R') || code.startsWith('C')) {
248
+ // Rename/copy: code, oldPath, newPath. The NEW path is what this
249
+ // commit produced (and the path subsequent commits see).
250
+ if (fields.length >= 3) pathAtCommit = fields[2];
251
+ } else {
252
+ pathAtCommit = fields[1];
253
+ }
254
+ }
255
+ i++;
256
+ }
257
+ const message = msgLines.join('\n').trimEnd();
258
+ out.push({
259
+ sha,
260
+ authorName: author || null,
261
+ authorEmail: email || null,
262
+ committedAt,
263
+ message,
264
+ pathAtCommit,
265
+ });
266
+ }
267
+ return out;
268
+ }
269
+
270
+ export interface DiffHunk {
271
+ oldStart: number; oldLines: number;
272
+ newStart: number; newLines: number;
273
+ }
274
+
275
+ export interface FileDiffInfo {
276
+ hunks: DiffHunk[];
277
+ /**
278
+ * True when the commit created the file (or — symmetrically — deleted it).
279
+ * In that case the diff has `--- /dev/null` (added) or `+++ /dev/null`
280
+ * (deleted) and EVERY symbol currently in the file should be attributed,
281
+ * because the file's current shape didn't exist before.
282
+ */
283
+ isFileAddition: boolean;
284
+ }
285
+
286
+ /**
287
+ * Diff hunks for a file changed by a single commit. `git show -U0 <sha>`
288
+ * handles the root commit transparently (no parent → diff against the empty
289
+ * tree), so the caller doesn't need to pass parentSha. `parentSha` is kept
290
+ * for compatibility but ignored.
291
+ */
292
+ export async function fileDiffHunks(
293
+ repoRoot: string, _parentSha: string | null, sha: string, filePath: string,
294
+ ): Promise<DiffHunk[]> {
295
+ const info = await fileDiffInfo(repoRoot, sha, filePath);
296
+ return info.hunks;
297
+ }
298
+
299
+ /** Like fileDiffHunks but returns extra metadata used for "this commit
300
+ * created the file → match every symbol" heuristic.
301
+ *
302
+ * `filePath` may be either an absolute path or a repo-relative one. When the
303
+ * file was renamed across history, callers should pass the PATH-AT-COMMIT
304
+ * (resolved from `git log --follow --name-status`) so `git show` looks at
305
+ * the right side of the rename — passing the current path would return
306
+ * empty hunks for pre-rename commits and silently lose history.
307
+ */
308
+ export async function fileDiffInfo(
309
+ repoRoot: string, sha: string, filePath: string,
310
+ ): Promise<FileDiffInfo> {
311
+ if (!isGitRepo(repoRoot)) return { hunks: [], isFileAddition: false };
312
+ const rel = path.isAbsolute(filePath) ? path.relative(repoRoot, filePath) : filePath;
313
+ const args = ['-C', repoRoot, 'show', '--format=', '-U0', sha, '--', rel];
314
+ return new Promise(resolve => {
315
+ const proc = spawn('git', args, { stdio: ['ignore', 'pipe', 'pipe'] });
316
+ let buf = '';
317
+ proc.stdout.on('data', (c: Buffer) => { buf += c.toString('utf8'); });
318
+ proc.stderr.on('data', () => { /* */ });
319
+ proc.on('error', () => resolve({ hunks: [], isFileAddition: false }));
320
+ proc.on('close', () => {
321
+ const out: DiffHunk[] = [];
322
+ const re = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm;
323
+ let m;
324
+ while ((m = re.exec(buf)) !== null) {
325
+ out.push({
326
+ oldStart: parseInt(m[1], 10),
327
+ oldLines: m[2] ? parseInt(m[2], 10) : 1,
328
+ newStart: parseInt(m[3], 10),
329
+ newLines: m[4] ? parseInt(m[4], 10) : 1,
330
+ });
331
+ }
332
+ const isFileAddition = /^--- \/dev\/null$/m.test(buf) || /^new file mode/m.test(buf);
333
+ resolve({ hunks: out, isFileAddition });
334
+ });
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Diff numstat for one commit: returns added/removed line counts per file
340
+ * (or aggregated when filePath is given).
341
+ */
342
+ export async function commitNumstat(
343
+ repoRoot: string, sha: string, filePath?: string,
344
+ ): Promise<{ added: number; removed: number }> {
345
+ if (!isGitRepo(repoRoot)) return { added: 0, removed: 0 };
346
+ const args = ['-C', repoRoot, 'show', '--numstat', '--format=', sha];
347
+ if (filePath) args.push('--', path.relative(repoRoot, filePath));
348
+ return new Promise(resolve => {
349
+ const proc = spawn('git', args, { stdio: ['ignore', 'pipe', 'pipe'] });
350
+ let buf = '';
351
+ proc.stdout.on('data', (c: Buffer) => { buf += c.toString('utf8'); });
352
+ proc.on('error', () => resolve({ added: 0, removed: 0 }));
353
+ proc.on('close', () => {
354
+ let added = 0, removed = 0;
355
+ for (const line of buf.split('\n')) {
356
+ const parts = line.trim().split(/\s+/);
357
+ if (parts.length < 3) continue;
358
+ const a = parseInt(parts[0], 10);
359
+ const r = parseInt(parts[1], 10);
360
+ if (!isNaN(a)) added += a;
361
+ if (!isNaN(r)) removed += r;
362
+ }
363
+ resolve({ added, removed });
364
+ });
365
+ });
366
+ }
367
+
368
+ /**
369
+ * git diff name-only between two refs. Defaults to HEAD vs the working
370
+ * tree (uncommitted changes) — used by `detect_changes`. Returns absolute
371
+ * paths (after path.resolve(repoRoot, rel)).
372
+ */
373
+ export function gitChangedFiles(repoRoot: string, fromRef?: string, toRef?: string): string[] {
374
+ if (!isGitRepo(repoRoot)) return [];
375
+ const args = ['-C', repoRoot, 'diff', '--name-only'];
376
+ if (fromRef && toRef) args.push(`${fromRef}..${toRef}`);
377
+ else if (fromRef) args.push(fromRef);
378
+ // No refs → working-tree diff against HEAD.
379
+ const r = spawnSync('git', args, { encoding: 'utf8' });
380
+ if (r.status !== 0) return [];
381
+ return r.stdout.split('\n').filter(Boolean).map(rel => path.resolve(repoRoot, rel));
382
+ }
383
+
384
+ /**
385
+ * git diff -U0 hunks for one file between two refs (or working tree if refs
386
+ * omitted). Returns parsed hunk headers (line ranges in the new file) so
387
+ * `detect_changes` can compute which symbols overlap.
388
+ */
389
+ export function fileDiffHunksSync(
390
+ repoRoot: string, filePath: string, fromRef?: string, toRef?: string,
391
+ ): DiffHunk[] {
392
+ if (!isGitRepo(repoRoot)) return [];
393
+ if (!fs.existsSync(filePath)) return [];
394
+ const rel = path.relative(repoRoot, filePath);
395
+ const args = ['-C', repoRoot, 'diff', '-U0'];
396
+ if (fromRef && toRef) args.push(`${fromRef}..${toRef}`);
397
+ else if (fromRef) args.push(fromRef);
398
+ args.push('--', rel);
399
+ const r = spawnSync('git', args, { encoding: 'utf8' });
400
+ if (r.status !== 0) return [];
401
+ const out: DiffHunk[] = [];
402
+ const re = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm;
403
+ let m;
404
+ while ((m = re.exec(r.stdout)) !== null) {
405
+ out.push({
406
+ oldStart: parseInt(m[1], 10),
407
+ oldLines: m[2] ? parseInt(m[2], 10) : 1,
408
+ newStart: parseInt(m[3], 10),
409
+ newLines: m[4] ? parseInt(m[4], 10) : 1,
410
+ });
411
+ }
412
+ return out;
413
+ }
414
+
415
+ function normalize(p: string): string {
416
+ const n = p.replace(/\\/g, '/');
417
+ return process.platform === 'win32' ? n.toLowerCase() : n;
418
+ }
419
+
420
+ /**
421
+ * Extract GitHub-style PR numbers from a commit message. We accept the
422
+ * common conventions:
423
+ * "Merge pull request #1234 from ..."
424
+ * "Fix something (#1234)"
425
+ * "#1234"
426
+ * "PR #1234"
427
+ *
428
+ * Returns the first matched PR number, or null.
429
+ */
430
+ export function extractPrNumber(message: string): number | null {
431
+ if (!message) return null;
432
+ const merge = message.match(/Merge pull request #(\d+)/i);
433
+ if (merge) return parseInt(merge[1], 10);
434
+ // Trailing or inline `#1234` but not part of a hex-ish word
435
+ const m = message.match(/(?:^|[\s(])#(\d{1,7})\b/);
436
+ if (m) return parseInt(m[1], 10);
437
+ return null;
438
+ }
439
+
440
+ /**
441
+ * If `remoteUrl` is a GitHub URL (HTTPS or SSH), return the matching
442
+ * `https://github.com/<owner>/<repo>/pull/<n>` URL for a PR number.
443
+ * Returns null for non-GitHub remotes so we don't fabricate links to
444
+ * GitLab/Bitbucket/etc.
445
+ */
446
+ export function githubPrUrl(remoteUrl: string | null, prNumber: number): string | null {
447
+ if (!remoteUrl || !prNumber) return null;
448
+ // git@github.com:owner/repo.git
449
+ let m = remoteUrl.match(/^git@github\.com:([^/]+)\/([^/.]+)(?:\.git)?$/);
450
+ if (!m) m = remoteUrl.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?\/?$/);
451
+ if (!m) return null;
452
+ return `https://github.com/${m[1]}/${m[2]}/pull/${prNumber}`;
453
+ }