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,181 @@
1
+ /**
2
+ * Parser worker — runs in its own V8 isolate via `worker_threads`.
3
+ *
4
+ * Each worker owns ONE `ParserContext` (its own WASM heap + grammar cache).
5
+ * Workers do their own file I/O so the main thread keeps no prefetcher,
6
+ * just dispatches paths and drains results.
7
+ *
8
+ * Protocol (see `WorkerInput` / `WorkerOutput`):
9
+ * main → worker: { kind: 'parse', seq, abs, lang, expectedHash, maxFileBytes }
10
+ * main → worker: { kind: 'shutdown' }
11
+ * worker → main: { kind: 'ready' } (once, on startup)
12
+ * worker → main: { kind: 'parsed' | 'parse-error' | 'cached' | 'too-large' | 'io-error', seq, wasmResets, ... }
13
+ * worker → main: { kind: 'shutdown-ack' } (just before exit)
14
+ *
15
+ * The worker is intentionally dumb about WHAT to do with extractions — it
16
+ * just returns the FileExtraction. All DB writes happen on the main thread.
17
+ *
18
+ * Failure isolation: a WASM abort inside this worker is contained to this
19
+ * isolate. The `ParserContext` already auto-resets the WASM runtime after
20
+ * three consecutive failures. If the worker crashes outright the pool
21
+ * detects the exit and requeues the inflight job (subject to a per-job
22
+ * attempt limit so a poison file can't crash workers forever).
23
+ */
24
+ import { parentPort } from 'worker_threads';
25
+ import fs from 'fs';
26
+ import crypto from 'crypto';
27
+ import type { FileExtraction, Language } from '../types.js';
28
+ import { ParserContext } from './parserContext.js';
29
+
30
+ // ── Protocol types (exported via re-export from index.ts for callers) ────────
31
+
32
+ export type WorkerInput =
33
+ | {
34
+ kind: 'parse';
35
+ seq: number;
36
+ abs: string;
37
+ lang: Language;
38
+ /** Known DB hash for this file; if the just-read hash matches, skip parse. */
39
+ expectedHash: string | null;
40
+ /** 0 = no cap. */
41
+ maxFileBytes: number;
42
+ }
43
+ | { kind: 'shutdown' };
44
+
45
+ export type WorkerOutput =
46
+ /** Posted exactly once, after `Parser.init()` has succeeded. */
47
+ | { kind: 'ready' }
48
+ /** Read + hashed + parsed successfully. */
49
+ | {
50
+ kind: 'parsed';
51
+ seq: number;
52
+ hash: string;
53
+ lines: number;
54
+ size: number;
55
+ /** Cumulative ParserContext reset count in this worker isolate. */
56
+ wasmResets: number;
57
+ extraction: FileExtraction;
58
+ }
59
+ /** Read + hashed but parse returned null (tree-sitter gave up). */
60
+ | { kind: 'parse-error'; seq: number; hash: string; lines: number; size: number; wasmResets: number }
61
+ /** Read + hash matched `expectedHash` — no parse performed. */
62
+ | { kind: 'cached'; seq: number; hash: string; lines: number; size: number; wasmResets: number }
63
+ /** stat() reported size > maxFileBytes; file was not read. */
64
+ | { kind: 'too-large'; seq: number; size: number; wasmResets: number }
65
+ /** readFile or stat threw. */
66
+ | { kind: 'io-error'; seq: number; error: string; wasmResets: number }
67
+ /** Posted just before the worker calls process.exit(0) in response to shutdown. */
68
+ | { kind: 'shutdown-ack' };
69
+
70
+ // ── Worker bootstrap ─────────────────────────────────────────────────────────
71
+
72
+ if (!parentPort) {
73
+ throw new Error('parser/worker.ts must be loaded via worker_threads, not require()');
74
+ }
75
+
76
+ const port = parentPort;
77
+ const ctx = new ParserContext();
78
+
79
+ // Test-only crash hook. When `SEER_WORKER_TEST_CRASH_ON=<substr>` is set in
80
+ // the environment of the spawning process, the worker hard-crashes on any
81
+ // parse job whose `abs` contains `<substr>`. Used by `tests/parallel-recovery.ts`
82
+ // to verify that pool crash recovery (respawn + retry, attempt limit) works.
83
+ // Production never sets this variable.
84
+ const TEST_CRASH_ON: string | null =
85
+ (typeof process !== 'undefined' && process.env && process.env.SEER_WORKER_TEST_CRASH_ON)
86
+ ? process.env.SEER_WORKER_TEST_CRASH_ON
87
+ : null;
88
+
89
+ // Test-only reset hook. It lets worker-pool/indexer tests verify reset-count
90
+ // aggregation deterministically without inducing a real tree-sitter WASM abort.
91
+ // Production never sets this variable.
92
+ const TEST_FAKE_WASM_RESET_ON: string | null =
93
+ (typeof process !== 'undefined' && process.env && process.env.SEER_WORKER_TEST_FAKE_WASM_RESET_ON)
94
+ ? process.env.SEER_WORKER_TEST_FAKE_WASM_RESET_ON
95
+ : null;
96
+ let testExtraWasmResets = 0;
97
+
98
+ function post(msg: WorkerOutput): void {
99
+ port.postMessage(msg);
100
+ }
101
+
102
+ function sha256Short(content: string): string {
103
+ return crypto.createHash('sha256').update(content, 'utf8').digest('hex').slice(0, 16);
104
+ }
105
+
106
+ function wasmResetCount(): number {
107
+ return ctx.wasmResetCount() + testExtraWasmResets;
108
+ }
109
+
110
+ async function handleParse(job: Extract<WorkerInput, { kind: 'parse' }>): Promise<void> {
111
+ const { seq, abs, lang, expectedHash, maxFileBytes } = job;
112
+
113
+ // Test-only crash hook.
114
+ if (TEST_CRASH_ON && abs.includes(TEST_CRASH_ON)) {
115
+ // Hard-exit so the parent observes a non-zero `exit` event — the pool's
116
+ // crash path runs and we exercise respawn + retry + attempt-limit.
117
+ process.exit(13);
118
+ }
119
+ if (TEST_FAKE_WASM_RESET_ON && abs.includes(TEST_FAKE_WASM_RESET_ON)) {
120
+ testExtraWasmResets++;
121
+ }
122
+
123
+ // Size gate: only stat when a cap is in force, so the default-no-cap path
124
+ // is one syscall (the readFile) per file.
125
+ if (maxFileBytes > 0) {
126
+ let size: number;
127
+ try {
128
+ size = (await fs.promises.stat(abs)).size;
129
+ } catch (err) {
130
+ post({ kind: 'io-error', seq, error: String((err as Error)?.message ?? err), wasmResets: wasmResetCount() });
131
+ return;
132
+ }
133
+ if (size > maxFileBytes) {
134
+ post({ kind: 'too-large', seq, size, wasmResets: wasmResetCount() });
135
+ return;
136
+ }
137
+ }
138
+
139
+ let content: string;
140
+ try {
141
+ content = await fs.promises.readFile(abs, 'utf8');
142
+ } catch (err) {
143
+ post({ kind: 'io-error', seq, error: String((err as Error)?.message ?? err), wasmResets: wasmResetCount() });
144
+ return;
145
+ }
146
+
147
+ const hash = sha256Short(content);
148
+ const lines = content.split('\n').length;
149
+ const size = Buffer.byteLength(content, 'utf8');
150
+
151
+ // Cache hit — main thread gave us a known hash for this path, and it
152
+ // matches. Skip parsing entirely; main thread will still upsertFileWithCache
153
+ // (so touchedFileIds is updated and pruneFilesNotIn does not delete this).
154
+ if (expectedHash !== null && hash === expectedHash) {
155
+ post({ kind: 'cached', seq, hash, lines, size, wasmResets: wasmResetCount() });
156
+ return;
157
+ }
158
+
159
+ const extraction = await ctx.parseFile(content, abs, lang);
160
+ if (!extraction) {
161
+ post({ kind: 'parse-error', seq, hash, lines, size, wasmResets: wasmResetCount() });
162
+ return;
163
+ }
164
+ post({ kind: 'parsed', seq, hash, lines, size, wasmResets: wasmResetCount(), extraction });
165
+ }
166
+
167
+ port.on('message', (msg: WorkerInput) => {
168
+ if (msg.kind === 'shutdown') {
169
+ post({ kind: 'shutdown-ack' });
170
+ // Give the message a tick to flush before exit.
171
+ setImmediate(() => process.exit(0));
172
+ return;
173
+ }
174
+ // Errors inside handleParse must not crash the worker — they're caught,
175
+ // logged through the protocol, and the worker stays alive for the next job.
176
+ handleParse(msg).catch(err => {
177
+ post({ kind: 'io-error', seq: msg.seq, error: `worker internal: ${String((err as Error)?.message ?? err)}`, wasmResets: wasmResetCount() });
178
+ });
179
+ });
180
+
181
+ post({ kind: 'ready' });
@@ -0,0 +1,448 @@
1
+ /**
2
+ * WorkerPool — a fixed-size pool of parser workers with ordered draining,
3
+ * bounded out-of-order buffering, per-job attempt limits, and crash recovery.
4
+ *
5
+ * Used by the indexer's parallel-parsing path. The pool owns:
6
+ * - N workers, each its own V8 isolate + WASM heap.
7
+ * - A FIFO queue of pending jobs.
8
+ * - A small bounded buffer for out-of-order completions (results that
9
+ * finished ahead of the current head seq are held until the head
10
+ * advances). The buffer is bounded by `maxLag` so a slow head can't
11
+ * pile up unbounded memory.
12
+ * - An "inflight" map: workerId → currently-assigned job. On worker
13
+ * death this is what gets requeued.
14
+ *
15
+ * Design discipline:
16
+ * - Workers parse only. The pool never touches the DB or graph.
17
+ * - The consumer callback is invoked strictly in input order. Symbol IDs
18
+ * in the indexer depend on this for cross-run determinism.
19
+ * - Crashes are recoverable but bounded: a job that has crashed `maxAttempts`
20
+ * workers in a row is reported as a parse-error and the run continues.
21
+ * - Shutdown is graceful: drain inflight, send `shutdown` to each worker,
22
+ * await exit. Terminate is the hammer for tests.
23
+ */
24
+ import { Worker } from 'worker_threads';
25
+ import fs from 'fs';
26
+ import path from 'path';
27
+ import os from 'os';
28
+ import type { Language } from '../types.js';
29
+ import type { WorkerInput, WorkerOutput } from './worker.js';
30
+
31
+ // ── Public types ─────────────────────────────────────────────────────────────
32
+
33
+ export interface WorkerPoolOptions {
34
+ /** Number of worker threads. Defaults to min(8, max(1, availableParallelism()-1)). */
35
+ jobs?: number;
36
+ /** Path to the compiled worker .js. Defaults to dist/parser/worker.js next to this module. */
37
+ workerPath?: string;
38
+ /**
39
+ * Max gap between the head seq (next to drain) and any dispatched seq.
40
+ * Bounds the out-of-order buffer at `maxLag` results in memory.
41
+ * Default: `jobs * 4`.
42
+ */
43
+ maxLag?: number;
44
+ /**
45
+ * Max times the same seq can be reattempted after a worker crash before
46
+ * we give up and mark it parse-error. Defaults to 3. (One legitimate try +
47
+ * two retries through respawned workers.)
48
+ */
49
+ maxAttempts?: number;
50
+ }
51
+
52
+ export interface WorkItem {
53
+ abs: string;
54
+ lang: Language;
55
+ /** Known DB hash for this file (null if no DB row). */
56
+ expectedHash: string | null;
57
+ /** Per-file byte cap. 0 = no cap. */
58
+ maxFileBytes: number;
59
+ }
60
+
61
+ /** What the consumer callback receives. `result` is one of the worker output kinds (minus the lifecycle ones). */
62
+ export type PoolResult =
63
+ | Extract<WorkerOutput, { kind: 'parsed' }>
64
+ | Extract<WorkerOutput, { kind: 'parse-error' }>
65
+ | Extract<WorkerOutput, { kind: 'cached' }>
66
+ | Extract<WorkerOutput, { kind: 'too-large' }>
67
+ | Extract<WorkerOutput, { kind: 'io-error' }>;
68
+
69
+ export type ResultCallback = (seq: number, result: PoolResult, item: WorkItem) => void | Promise<void>;
70
+
71
+ // ── Worker handle ────────────────────────────────────────────────────────────
72
+
73
+ interface WorkerHandle {
74
+ id: number;
75
+ worker: Worker;
76
+ inflight: { seq: number; item: WorkItem } | null;
77
+ ready: boolean;
78
+ wasmResets: number;
79
+ }
80
+
81
+ // ── Default worker-path resolver ─────────────────────────────────────────────
82
+ //
83
+ // Workers always run from the compiled .js artifact. In dev (tsx) the source
84
+ // of this module is .ts; we reflect __dirname to dist. This means callers
85
+ // must `npm run build` before turning on parallel mode in dev — which matches
86
+ // the pattern already used by the MCP test suite. If the dist artifact is
87
+ // missing we throw with a clear message rather than silently fall back.
88
+
89
+ export function defaultWorkerPath(): string {
90
+ const here = __filename;
91
+ if (here.endsWith('.js')) {
92
+ return path.join(__dirname, 'worker.js');
93
+ }
94
+ // Source mode: reflect src/parser/<this>.ts → dist/parser/worker.js.
95
+ const distDir = __dirname
96
+ .replace(`${path.sep}src${path.sep}parser`, `${path.sep}dist${path.sep}parser`)
97
+ .replace('/src/parser', '/dist/parser');
98
+ const distWorker = path.join(distDir, 'worker.js');
99
+ if (!fs.existsSync(distWorker)) {
100
+ throw new Error(
101
+ `Parallel parsing requires the compiled worker at ${distWorker}. ` +
102
+ `Run \`npm run build\` first, or set SEER_PARALLEL_PARSE=0.`,
103
+ );
104
+ }
105
+ return distWorker;
106
+ }
107
+
108
+ // ── WorkerPool ───────────────────────────────────────────────────────────────
109
+
110
+ export class WorkerPool {
111
+ private readonly workerPath: string;
112
+ private readonly maxLag: number;
113
+ private readonly maxAttempts: number;
114
+ private readonly _jobs: number;
115
+
116
+ private workers: WorkerHandle[] = [];
117
+ private idle: WorkerHandle[] = [];
118
+ private nextWorkerId = 0;
119
+
120
+ /** Sequence number of the next result we expect to deliver via the callback. */
121
+ private headSeq = 0;
122
+ /** Total jobs in the current dispatch. */
123
+ private totalJobs = 0;
124
+ /** Next seq to send to a worker. */
125
+ private nextDispatchSeq = 0;
126
+ /** seq → WorkItem (for the entire dispatch). */
127
+ private items: WorkItem[] = [];
128
+ /** seq → attempt count. */
129
+ private attempts = new Map<number, number>();
130
+ /** Out-of-order buffer for seq > headSeq. */
131
+ private buffered = new Map<number, PoolResult>();
132
+ /** Awaiting-worker queue: seqs that need to be dispatched but no worker is free. */
133
+ private pendingDispatch: number[] = [];
134
+ /** Currently active dispatch — null when idle. */
135
+ private active: {
136
+ onResult: ResultCallback;
137
+ resolve: () => void;
138
+ reject: (err: Error) => void;
139
+ delivering: Promise<void>;
140
+ } | null = null;
141
+ /**
142
+ * Synchronous flag set the moment `dispatch()` is entered. Prevents two
143
+ * synchronous `dispatch()` calls from both passing the `if (this.active)`
144
+ * guard before the first reaches its `await this.ready()`. `this.active`
145
+ * itself is set after that await, so it can't be the only guard.
146
+ */
147
+ private dispatching = false;
148
+ /** A rolling promise chain so the consumer callback is invoked strictly serially. */
149
+ private callbackChain: Promise<void> = Promise.resolve();
150
+ private _wasmResets = 0;
151
+
152
+ private terminated = false;
153
+
154
+ constructor(opts: WorkerPoolOptions = {}) {
155
+ this._jobs = Math.max(1, opts.jobs ?? defaultJobCount());
156
+ this.workerPath = opts.workerPath ?? defaultWorkerPath();
157
+ this.maxLag = Math.max(this._jobs, opts.maxLag ?? this._jobs * 4);
158
+ this.maxAttempts = Math.max(1, opts.maxAttempts ?? 3);
159
+ }
160
+
161
+ /** Number of workers actually spawned (after `ready()`). */
162
+ get jobs(): number { return this._jobs; }
163
+
164
+ /** Total worker-local ParserContext resets observed across this pool. */
165
+ wasmResetCount(): number { return this._wasmResets; }
166
+
167
+ /** Spawn workers and wait for all of them to post `ready`. */
168
+ async ready(): Promise<void> {
169
+ if (this.workers.length > 0) return; // already spawned
170
+ const readyPromises: Array<Promise<void>> = [];
171
+ for (let i = 0; i < this._jobs; i++) {
172
+ const handle = this.spawnWorker();
173
+ this.workers.push(handle);
174
+ readyPromises.push(
175
+ new Promise<void>((resolve, reject) => {
176
+ handle.worker.once('error', reject);
177
+ const onMsg = (msg: WorkerOutput): void => {
178
+ if (msg.kind === 'ready') {
179
+ handle.ready = true;
180
+ this.idle.push(handle);
181
+ handle.worker.off('message', onMsg);
182
+ resolve();
183
+ }
184
+ };
185
+ handle.worker.on('message', onMsg);
186
+ }),
187
+ );
188
+ }
189
+ await Promise.all(readyPromises);
190
+ }
191
+
192
+ /**
193
+ * Dispatch every WorkItem to the pool. The callback is invoked exactly once
194
+ * per item, in input order, regardless of which worker finished it.
195
+ *
196
+ * Resolves when every item has been delivered to the callback. Rejects if
197
+ * the pool is terminated mid-dispatch.
198
+ */
199
+ async dispatch(items: WorkItem[], onResult: ResultCallback): Promise<void> {
200
+ if (this.dispatching || this.active) {
201
+ throw new Error('WorkerPool: dispatch already in progress');
202
+ }
203
+ if (this.terminated) {
204
+ throw new Error('WorkerPool: terminated');
205
+ }
206
+ if (items.length === 0) return;
207
+ this.dispatching = true;
208
+ try {
209
+ await this.ready();
210
+
211
+ this.items = items;
212
+ this.totalJobs = items.length;
213
+ this.headSeq = 0;
214
+ this.nextDispatchSeq = 0;
215
+ this.attempts.clear();
216
+ this.buffered.clear();
217
+ this.pendingDispatch.length = 0;
218
+ this.callbackChain = Promise.resolve();
219
+
220
+ await new Promise<void>((resolve, reject) => {
221
+ this.active = {
222
+ onResult,
223
+ resolve,
224
+ reject,
225
+ delivering: this.callbackChain,
226
+ };
227
+ this.pump();
228
+ });
229
+
230
+ // Wait for the callback chain to fully drain before returning.
231
+ await this.callbackChain;
232
+ this.active = null;
233
+ this.items = [];
234
+ } finally {
235
+ this.dispatching = false;
236
+ }
237
+ }
238
+
239
+ /** Shutdown: send 'shutdown' to each worker, await all exits. */
240
+ async shutdown(): Promise<void> {
241
+ if (this.terminated) return;
242
+ this.terminated = true;
243
+ const exits: Array<Promise<void>> = [];
244
+ for (const h of this.workers) {
245
+ exits.push(new Promise<void>(resolve => {
246
+ h.worker.once('exit', () => resolve());
247
+ try { h.worker.postMessage({ kind: 'shutdown' } satisfies WorkerInput); }
248
+ catch { resolve(); }
249
+ }));
250
+ }
251
+ await Promise.all(exits);
252
+ this.workers = [];
253
+ this.idle = [];
254
+ }
255
+
256
+ /** Hammer: force-terminate all workers. Use only when shutdown() can't. */
257
+ async terminate(): Promise<void> {
258
+ if (this.terminated && this.workers.length === 0) return;
259
+ this.terminated = true;
260
+ const exits: Array<Promise<number>> = [];
261
+ for (const h of this.workers) exits.push(h.worker.terminate());
262
+ await Promise.all(exits);
263
+ this.workers = [];
264
+ this.idle = [];
265
+ }
266
+
267
+ // ── Internals ──────────────────────────────────────────────────────────────
268
+
269
+ /** Pump dispatch + drain until either no work is left or no slack remains. */
270
+ private pump(): void {
271
+ // 1. Dispatch as many new seqs as backpressure allows.
272
+ // Constraint: seqDispatched < headSeq + maxLag, so the buffered out-of-order
273
+ // set can't grow past `maxLag` entries.
274
+ while (
275
+ this.idle.length > 0
276
+ && this.nextDispatchSeq < this.totalJobs
277
+ && this.nextDispatchSeq < this.headSeq + this.maxLag
278
+ ) {
279
+ const seq = this.nextDispatchSeq++;
280
+ const handle = this.idle.pop()!;
281
+ this.sendJob(handle, seq);
282
+ }
283
+ // 2. Also drain any pendingDispatch (requeued after a crash) while workers idle.
284
+ while (this.idle.length > 0 && this.pendingDispatch.length > 0) {
285
+ const seq = this.pendingDispatch.shift()!;
286
+ const handle = this.idle.pop()!;
287
+ this.sendJob(handle, seq);
288
+ }
289
+ // 3. If everything is delivered, finish the dispatch.
290
+ if (this.active && this.headSeq >= this.totalJobs) {
291
+ const a = this.active;
292
+ // Resolve once the callback chain has drained the last buffered result.
293
+ this.callbackChain.then(() => a.resolve(), err => a.reject(err));
294
+ }
295
+ }
296
+
297
+ private sendJob(handle: WorkerHandle, seq: number): void {
298
+ const item = this.items[seq];
299
+ handle.inflight = { seq, item };
300
+ const job: WorkerInput = {
301
+ kind: 'parse',
302
+ seq,
303
+ abs: item.abs,
304
+ lang: item.lang,
305
+ expectedHash: item.expectedHash,
306
+ maxFileBytes: item.maxFileBytes,
307
+ };
308
+ try {
309
+ handle.worker.postMessage(job);
310
+ } catch (err) {
311
+ // Worker is gone — treat as crash.
312
+ this.onWorkerCrash(handle, err as Error);
313
+ }
314
+ }
315
+
316
+ private spawnWorker(): WorkerHandle {
317
+ const id = this.nextWorkerId++;
318
+ const worker = new Worker(this.workerPath);
319
+ const handle: WorkerHandle = { id, worker, inflight: null, ready: false, wasmResets: 0 };
320
+
321
+ worker.on('message', (msg: WorkerOutput) => this.onWorkerMessage(handle, msg));
322
+ worker.on('error', err => this.onWorkerCrash(handle, err));
323
+ worker.on('exit', code => {
324
+ if (this.terminated) return;
325
+ if (handle.inflight) {
326
+ // Unexpected exit during a job.
327
+ this.onWorkerCrash(handle, new Error(`worker ${id} exited with code ${code} mid-job`));
328
+ return;
329
+ }
330
+ // Exited idle — only fine if we're tearing down. If we're still active,
331
+ // respawn so the pool doesn't shrink under load.
332
+ if (this.active && !this.terminated) {
333
+ this.respawnWorker(handle);
334
+ }
335
+ });
336
+ return handle;
337
+ }
338
+
339
+ private respawnWorker(dead: WorkerHandle): void {
340
+ const idx = this.workers.indexOf(dead);
341
+ if (idx >= 0) this.workers.splice(idx, 1);
342
+ const idleIdx = this.idle.indexOf(dead);
343
+ if (idleIdx >= 0) this.idle.splice(idleIdx, 1);
344
+
345
+ const fresh = this.spawnWorker();
346
+ this.workers.push(fresh);
347
+ // Wait for fresh worker's ready message before counting it as idle.
348
+ const onMsg = (msg: WorkerOutput): void => {
349
+ if (msg.kind === 'ready') {
350
+ fresh.ready = true;
351
+ fresh.worker.off('message', onMsg);
352
+ this.idle.push(fresh);
353
+ if (this.active) this.pump();
354
+ }
355
+ };
356
+ fresh.worker.on('message', onMsg);
357
+ }
358
+
359
+ private onWorkerCrash(handle: WorkerHandle, err: Error): void {
360
+ const job = handle.inflight;
361
+ handle.inflight = null;
362
+
363
+ if (job) {
364
+ const prior = this.attempts.get(job.seq) ?? 0;
365
+ const attempts = prior + 1;
366
+ this.attempts.set(job.seq, attempts);
367
+
368
+ if (attempts >= this.maxAttempts) {
369
+ // Give up on this seq — synthesize a parse-error so the dispatch can drain.
370
+ this.deliver(job.seq, {
371
+ kind: 'parse-error',
372
+ seq: job.seq,
373
+ hash: '',
374
+ lines: 0,
375
+ size: 0,
376
+ wasmResets: handle.wasmResets,
377
+ });
378
+ } else {
379
+ // Retry on another worker.
380
+ this.pendingDispatch.push(job.seq);
381
+ }
382
+ }
383
+
384
+ if (!this.terminated && this.active) {
385
+ this.respawnWorker(handle);
386
+ } else {
387
+ // Out-of-band crash — surface only if no active dispatch is going to swallow it.
388
+ if (this.active && !job) this.active.reject(err);
389
+ }
390
+ }
391
+
392
+ private onWorkerMessage(handle: WorkerHandle, msg: WorkerOutput): void {
393
+ if (msg.kind === 'ready' || msg.kind === 'shutdown-ack') return;
394
+
395
+ const seq = (msg as { seq: number }).seq;
396
+ // Worker becomes idle again right away.
397
+ if (handle.inflight && handle.inflight.seq === seq) handle.inflight = null;
398
+ if (!this.idle.includes(handle)) this.idle.push(handle);
399
+
400
+ if ('wasmResets' in msg) {
401
+ const total = msg.wasmResets;
402
+ const delta = Math.max(0, total - handle.wasmResets);
403
+ handle.wasmResets = total;
404
+ this._wasmResets += delta;
405
+ }
406
+
407
+ this.deliver(seq, msg as PoolResult);
408
+ }
409
+
410
+ private deliver(seq: number, result: PoolResult): void {
411
+ if (!this.active) return; // late message after shutdown
412
+ if (seq === this.headSeq) {
413
+ this.invokeCallback(seq, result);
414
+ this.headSeq++;
415
+ // Drain any contiguous buffered successors.
416
+ while (this.buffered.has(this.headSeq)) {
417
+ const next = this.buffered.get(this.headSeq)!;
418
+ this.buffered.delete(this.headSeq);
419
+ this.invokeCallback(this.headSeq, next);
420
+ this.headSeq++;
421
+ }
422
+ } else {
423
+ this.buffered.set(seq, result);
424
+ }
425
+ this.pump();
426
+ }
427
+
428
+ private invokeCallback(seq: number, result: PoolResult): void {
429
+ if (!this.active) return;
430
+ const a = this.active;
431
+ const item = this.items[seq];
432
+ this.callbackChain = this.callbackChain.then(() => Promise.resolve(a.onResult(seq, result, item)));
433
+ // If the callback rejects, surface it through the active dispatch.
434
+ this.callbackChain = this.callbackChain.catch(err => {
435
+ if (this.active) this.active.reject(err);
436
+ });
437
+ }
438
+ }
439
+
440
+ // ── Helpers ──────────────────────────────────────────────────────────────────
441
+
442
+ function defaultJobCount(): number {
443
+ // availableParallelism is the most accurate counter on cgroup-limited
444
+ // containers; fall back to cpus().length on older Node where it's missing.
445
+ const fn = (os as unknown as { availableParallelism?: () => number }).availableParallelism;
446
+ const cores = typeof fn === 'function' ? fn() : os.cpus().length;
447
+ return Math.min(8, Math.max(1, cores - 1));
448
+ }