@memlab/mcp-server 2.18.1 → 2.86.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 (374) hide show
  1. package/README.md +30 -16
  2. package/bin/.eslintrc.js +26 -0
  3. package/bin/memlab-cli.js +313 -0
  4. package/dist/anonymized-snapshot.d.ts +75 -0
  5. package/dist/anonymized-snapshot.d.ts.map +1 -0
  6. package/dist/anonymized-snapshot.js +101 -0
  7. package/dist/anonymized-snapshot.js.map +1 -0
  8. package/dist/artifact-classes.d.ts +86 -0
  9. package/dist/artifact-classes.d.ts.map +1 -0
  10. package/dist/artifact-classes.js +261 -0
  11. package/dist/artifact-classes.js.map +1 -0
  12. package/dist/capture-mode.d.ts +60 -0
  13. package/dist/capture-mode.d.ts.map +1 -0
  14. package/dist/capture-mode.js +39 -0
  15. package/dist/capture-mode.js.map +1 -0
  16. package/dist/eval-hints.d.ts +41 -0
  17. package/dist/eval-hints.d.ts.map +1 -0
  18. package/dist/eval-hints.js +115 -0
  19. package/dist/eval-hints.js.map +1 -0
  20. package/dist/guardrail.d.ts.map +1 -1
  21. package/dist/guardrail.js +13 -0
  22. package/dist/guardrail.js.map +1 -1
  23. package/dist/heap-budget.d.ts +32 -0
  24. package/dist/heap-budget.d.ts.map +1 -0
  25. package/dist/heap-budget.js +90 -0
  26. package/dist/heap-budget.js.map +1 -0
  27. package/dist/heap-shapes.d.ts +148 -0
  28. package/dist/heap-shapes.d.ts.map +1 -0
  29. package/dist/heap-shapes.js +193 -0
  30. package/dist/heap-shapes.js.map +1 -0
  31. package/dist/heap-state.d.ts +109 -2
  32. package/dist/heap-state.d.ts.map +1 -1
  33. package/dist/heap-state.js +194 -3
  34. package/dist/heap-state.js.map +1 -1
  35. package/dist/index.js +102 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/progress.d.ts +25 -0
  38. package/dist/progress.d.ts.map +1 -0
  39. package/dist/progress.js +46 -0
  40. package/dist/progress.js.map +1 -0
  41. package/dist/react-shapes.d.ts +42 -0
  42. package/dist/react-shapes.d.ts.map +1 -0
  43. package/dist/react-shapes.js +149 -0
  44. package/dist/react-shapes.js.map +1 -0
  45. package/dist/result-handles.d.ts +11 -0
  46. package/dist/result-handles.d.ts.map +1 -0
  47. package/dist/result-handles.js +77 -0
  48. package/dist/result-handles.js.map +1 -0
  49. package/dist/server-version.d.ts +19 -0
  50. package/dist/server-version.d.ts.map +1 -0
  51. package/dist/server-version.js +19 -0
  52. package/dist/server-version.js.map +1 -0
  53. package/dist/snapshot-borrow.d.ts +67 -0
  54. package/dist/snapshot-borrow.d.ts.map +1 -0
  55. package/dist/snapshot-borrow.js +113 -0
  56. package/dist/snapshot-borrow.js.map +1 -0
  57. package/dist/string-index.d.ts +55 -0
  58. package/dist/string-index.d.ts.map +1 -0
  59. package/dist/string-index.js +99 -0
  60. package/dist/string-index.js.map +1 -0
  61. package/dist/tool-registry.d.ts +42 -0
  62. package/dist/tool-registry.d.ts.map +1 -0
  63. package/dist/tool-registry.js +20 -0
  64. package/dist/tool-registry.js.map +1 -0
  65. package/dist/tools/analyze-run.d.ts +12 -0
  66. package/dist/tools/analyze-run.d.ts.map +1 -0
  67. package/dist/tools/analyze-run.js +404 -0
  68. package/dist/tools/analyze-run.js.map +1 -0
  69. package/dist/tools/anonymize-snapshot.d.ts +12 -0
  70. package/dist/tools/anonymize-snapshot.d.ts.map +1 -0
  71. package/dist/tools/anonymize-snapshot.js +159 -0
  72. package/dist/tools/anonymize-snapshot.js.map +1 -0
  73. package/dist/tools/app-config.d.ts +12 -0
  74. package/dist/tools/app-config.d.ts.map +1 -0
  75. package/dist/tools/app-config.js +217 -0
  76. package/dist/tools/app-config.js.map +1 -0
  77. package/dist/tools/app-heap.d.ts +30 -0
  78. package/dist/tools/app-heap.d.ts.map +1 -0
  79. package/dist/tools/app-heap.js +106 -0
  80. package/dist/tools/app-heap.js.map +1 -0
  81. package/dist/tools/artifact-budget.d.ts +12 -0
  82. package/dist/tools/artifact-budget.d.ts.map +1 -0
  83. package/dist/tools/artifact-budget.js +219 -0
  84. package/dist/tools/artifact-budget.js.map +1 -0
  85. package/dist/tools/async-census.d.ts +12 -0
  86. package/dist/tools/async-census.d.ts.map +1 -0
  87. package/dist/tools/async-census.js +243 -0
  88. package/dist/tools/async-census.js.map +1 -0
  89. package/dist/tools/auto-investigate.d.ts.map +1 -1
  90. package/dist/tools/auto-investigate.js +54 -5
  91. package/dist/tools/auto-investigate.js.map +1 -1
  92. package/dist/tools/batch.d.ts +12 -0
  93. package/dist/tools/batch.d.ts.map +1 -0
  94. package/dist/tools/batch.js +196 -0
  95. package/dist/tools/batch.js.map +1 -0
  96. package/dist/tools/cache-analysis.d.ts +15 -0
  97. package/dist/tools/cache-analysis.d.ts.map +1 -1
  98. package/dist/tools/cache-analysis.js +240 -14
  99. package/dist/tools/cache-analysis.js.map +1 -1
  100. package/dist/tools/census-diff.d.ts +55 -0
  101. package/dist/tools/census-diff.d.ts.map +1 -0
  102. package/dist/tools/census-diff.js +228 -0
  103. package/dist/tools/census-diff.js.map +1 -0
  104. package/dist/tools/chain-walk.d.ts +12 -0
  105. package/dist/tools/chain-walk.d.ts.map +1 -0
  106. package/dist/tools/chain-walk.js +184 -0
  107. package/dist/tools/chain-walk.js.map +1 -0
  108. package/dist/tools/class-histogram.d.ts.map +1 -1
  109. package/dist/tools/class-histogram.js +14 -3
  110. package/dist/tools/class-histogram.js.map +1 -1
  111. package/dist/tools/collection-diff.d.ts +44 -0
  112. package/dist/tools/collection-diff.d.ts.map +1 -0
  113. package/dist/tools/collection-diff.js +343 -0
  114. package/dist/tools/collection-diff.js.map +1 -0
  115. package/dist/tools/collection-trend.d.ts +55 -0
  116. package/dist/tools/collection-trend.d.ts.map +1 -0
  117. package/dist/tools/collection-trend.js +315 -0
  118. package/dist/tools/collection-trend.js.map +1 -0
  119. package/dist/tools/detached-dom.d.ts +5 -0
  120. package/dist/tools/detached-dom.d.ts.map +1 -1
  121. package/dist/tools/detached-dom.js +368 -22
  122. package/dist/tools/detached-dom.js.map +1 -1
  123. package/dist/tools/dev-artifacts.d.ts +62 -6
  124. package/dist/tools/dev-artifacts.d.ts.map +1 -1
  125. package/dist/tools/dev-artifacts.js +685 -24
  126. package/dist/tools/dev-artifacts.js.map +1 -1
  127. package/dist/tools/diff-snapshots.d.ts.map +1 -1
  128. package/dist/tools/diff-snapshots.js +18 -2
  129. package/dist/tools/diff-snapshots.js.map +1 -1
  130. package/dist/tools/dom-audit.d.ts +37 -0
  131. package/dist/tools/dom-audit.d.ts.map +1 -0
  132. package/dist/tools/dom-audit.js +247 -0
  133. package/dist/tools/dom-audit.js.map +1 -0
  134. package/dist/tools/dominator-attribution.d.ts +12 -0
  135. package/dist/tools/dominator-attribution.d.ts.map +1 -0
  136. package/dist/tools/dominator-attribution.js +189 -0
  137. package/dist/tools/dominator-attribution.js.map +1 -0
  138. package/dist/tools/dominator-chain.d.ts +12 -0
  139. package/dist/tools/dominator-chain.d.ts.map +1 -0
  140. package/dist/tools/dominator-chain.js +92 -0
  141. package/dist/tools/dominator-chain.js.map +1 -0
  142. package/dist/tools/duplicate-objects.d.ts +12 -0
  143. package/dist/tools/duplicate-objects.d.ts.map +1 -0
  144. package/dist/tools/duplicate-objects.js +172 -0
  145. package/dist/tools/duplicate-objects.js.map +1 -0
  146. package/dist/tools/duplicated-strings.d.ts.map +1 -1
  147. package/dist/tools/duplicated-strings.js +49 -40
  148. package/dist/tools/duplicated-strings.js.map +1 -1
  149. package/dist/tools/eval-across.d.ts +12 -0
  150. package/dist/tools/eval-across.d.ts.map +1 -0
  151. package/dist/tools/eval-across.js +135 -0
  152. package/dist/tools/eval-across.js.map +1 -0
  153. package/dist/tools/eval.d.ts +79 -0
  154. package/dist/tools/eval.d.ts.map +1 -1
  155. package/dist/tools/eval.js +2523 -197
  156. package/dist/tools/eval.js.map +1 -1
  157. package/dist/tools/event-listener-leaks.d.ts.map +1 -1
  158. package/dist/tools/event-listener-leaks.js +42 -17
  159. package/dist/tools/event-listener-leaks.js.map +1 -1
  160. package/dist/tools/event-registry.d.ts.map +1 -1
  161. package/dist/tools/event-registry.js +42 -8
  162. package/dist/tools/event-registry.js.map +1 -1
  163. package/dist/tools/explain-delta.d.ts +12 -0
  164. package/dist/tools/explain-delta.d.ts.map +1 -0
  165. package/dist/tools/explain-delta.js +221 -0
  166. package/dist/tools/explain-delta.js.map +1 -0
  167. package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
  168. package/dist/tools/find-nodes-by-class.js +112 -13
  169. package/dist/tools/find-nodes-by-class.js.map +1 -1
  170. package/dist/tools/finding-index.d.ts +203 -0
  171. package/dist/tools/finding-index.d.ts.map +1 -0
  172. package/dist/tools/finding-index.js +566 -0
  173. package/dist/tools/finding-index.js.map +1 -0
  174. package/dist/tools/for-each.d.ts.map +1 -1
  175. package/dist/tools/for-each.js +15 -3
  176. package/dist/tools/for-each.js.map +1 -1
  177. package/dist/tools/get-referrers.d.ts.map +1 -1
  178. package/dist/tools/get-referrers.js +17 -0
  179. package/dist/tools/get-referrers.js.map +1 -1
  180. package/dist/tools/get-value.d.ts +16 -0
  181. package/dist/tools/get-value.d.ts.map +1 -1
  182. package/dist/tools/get-value.js +96 -2
  183. package/dist/tools/get-value.js.map +1 -1
  184. package/dist/tools/hunt-report.d.ts +12 -0
  185. package/dist/tools/hunt-report.d.ts.map +1 -0
  186. package/dist/tools/hunt-report.js +186 -0
  187. package/dist/tools/hunt-report.js.map +1 -0
  188. package/dist/tools/hypothesis.d.ts +12 -0
  189. package/dist/tools/hypothesis.d.ts.map +1 -0
  190. package/dist/tools/hypothesis.js +280 -0
  191. package/dist/tools/hypothesis.js.map +1 -0
  192. package/dist/tools/id-space-audit.d.ts +12 -0
  193. package/dist/tools/id-space-audit.d.ts.map +1 -0
  194. package/dist/tools/id-space-audit.js +262 -0
  195. package/dist/tools/id-space-audit.js.map +1 -0
  196. package/dist/tools/identify.d.ts +12 -0
  197. package/dist/tools/identify.d.ts.map +1 -0
  198. package/dist/tools/identify.js +205 -0
  199. package/dist/tools/identify.js.map +1 -0
  200. package/dist/tools/identity-diff.d.ts +12 -0
  201. package/dist/tools/identity-diff.d.ts.map +1 -0
  202. package/dist/tools/identity-diff.js +188 -0
  203. package/dist/tools/identity-diff.js.map +1 -0
  204. package/dist/tools/intern-opportunities.d.ts.map +1 -1
  205. package/dist/tools/intern-opportunities.js +116 -50
  206. package/dist/tools/intern-opportunities.js.map +1 -1
  207. package/dist/tools/ladder-probe.d.ts +65 -0
  208. package/dist/tools/ladder-probe.d.ts.map +1 -0
  209. package/dist/tools/ladder-probe.js +445 -0
  210. package/dist/tools/ladder-probe.js.map +1 -0
  211. package/dist/tools/ladder.d.ts +33 -0
  212. package/dist/tools/ladder.d.ts.map +1 -0
  213. package/dist/tools/ladder.js +266 -0
  214. package/dist/tools/ladder.js.map +1 -0
  215. package/dist/tools/largest-objects.d.ts.map +1 -1
  216. package/dist/tools/largest-objects.js +6 -2
  217. package/dist/tools/largest-objects.js.map +1 -1
  218. package/dist/tools/leak-report.d.ts +35 -0
  219. package/dist/tools/leak-report.d.ts.map +1 -0
  220. package/dist/tools/leak-report.js +398 -0
  221. package/dist/tools/leak-report.js.map +1 -0
  222. package/dist/tools/load-snapshot.d.ts +29 -0
  223. package/dist/tools/load-snapshot.d.ts.map +1 -1
  224. package/dist/tools/load-snapshot.js +294 -23
  225. package/dist/tools/load-snapshot.js.map +1 -1
  226. package/dist/tools/map-entries.d.ts +12 -0
  227. package/dist/tools/map-entries.d.ts.map +1 -0
  228. package/dist/tools/map-entries.js +139 -0
  229. package/dist/tools/map-entries.js.map +1 -0
  230. package/dist/tools/match-object.d.ts +12 -0
  231. package/dist/tools/match-object.d.ts.map +1 -0
  232. package/dist/tools/match-object.js +165 -0
  233. package/dist/tools/match-object.js.map +1 -0
  234. package/dist/tools/metric.d.ts +13 -0
  235. package/dist/tools/metric.d.ts.map +1 -0
  236. package/dist/tools/metric.js +185 -0
  237. package/dist/tools/metric.js.map +1 -0
  238. package/dist/tools/module-attribution.d.ts +36 -0
  239. package/dist/tools/module-attribution.d.ts.map +1 -0
  240. package/dist/tools/module-attribution.js +235 -0
  241. package/dist/tools/module-attribution.js.map +1 -0
  242. package/dist/tools/next-measurement.d.ts +26 -0
  243. package/dist/tools/next-measurement.d.ts.map +1 -0
  244. package/dist/tools/next-measurement.js +149 -0
  245. package/dist/tools/next-measurement.js.map +1 -0
  246. package/dist/tools/object-cost-breakdown.d.ts.map +1 -1
  247. package/dist/tools/object-cost-breakdown.js +64 -0
  248. package/dist/tools/object-cost-breakdown.js.map +1 -1
  249. package/dist/tools/object-shape.d.ts.map +1 -1
  250. package/dist/tools/object-shape.js +53 -5
  251. package/dist/tools/object-shape.js.map +1 -1
  252. package/dist/tools/population-diff.d.ts +57 -0
  253. package/dist/tools/population-diff.d.ts.map +1 -0
  254. package/dist/tools/population-diff.js +338 -0
  255. package/dist/tools/population-diff.js.map +1 -0
  256. package/dist/tools/population-vs-owners.d.ts +12 -0
  257. package/dist/tools/population-vs-owners.d.ts.map +1 -0
  258. package/dist/tools/population-vs-owners.js +183 -0
  259. package/dist/tools/population-vs-owners.js.map +1 -0
  260. package/dist/tools/property-names.d.ts +29 -0
  261. package/dist/tools/property-names.d.ts.map +1 -0
  262. package/dist/tools/property-names.js +131 -0
  263. package/dist/tools/property-names.js.map +1 -0
  264. package/dist/tools/quick-diagnosis.d.ts.map +1 -1
  265. package/dist/tools/quick-diagnosis.js +7 -1
  266. package/dist/tools/quick-diagnosis.js.map +1 -1
  267. package/dist/tools/react-owners.d.ts +12 -0
  268. package/dist/tools/react-owners.d.ts.map +1 -0
  269. package/dist/tools/react-owners.js +167 -0
  270. package/dist/tools/react-owners.js.map +1 -0
  271. package/dist/tools/react-update-queues.d.ts +12 -0
  272. package/dist/tools/react-update-queues.d.ts.map +1 -0
  273. package/dist/tools/react-update-queues.js +173 -0
  274. package/dist/tools/react-update-queues.js.map +1 -0
  275. package/dist/tools/replicate.d.ts +49 -0
  276. package/dist/tools/replicate.d.ts.map +1 -0
  277. package/dist/tools/replicate.js +241 -0
  278. package/dist/tools/replicate.js.map +1 -0
  279. package/dist/tools/retainer-diff.d.ts +12 -0
  280. package/dist/tools/retainer-diff.d.ts.map +1 -0
  281. package/dist/tools/retainer-diff.js +168 -0
  282. package/dist/tools/retainer-diff.js.map +1 -0
  283. package/dist/tools/retainer-layers.d.ts +23 -0
  284. package/dist/tools/retainer-layers.d.ts.map +1 -0
  285. package/dist/tools/retainer-layers.js +179 -0
  286. package/dist/tools/retainer-layers.js.map +1 -0
  287. package/dist/tools/retainer-summary.d.ts +18 -0
  288. package/dist/tools/retainer-summary.d.ts.map +1 -1
  289. package/dist/tools/retainer-summary.js +210 -51
  290. package/dist/tools/retainer-summary.js.map +1 -1
  291. package/dist/tools/retainer-trace.d.ts.map +1 -1
  292. package/dist/tools/retainer-trace.js +14 -2
  293. package/dist/tools/retainer-trace.js.map +1 -1
  294. package/dist/tools/round-audit.d.ts +66 -0
  295. package/dist/tools/round-audit.d.ts.map +1 -0
  296. package/dist/tools/round-audit.js +258 -0
  297. package/dist/tools/round-audit.js.map +1 -0
  298. package/dist/tools/round-digest.d.ts +12 -0
  299. package/dist/tools/round-digest.d.ts.map +1 -0
  300. package/dist/tools/round-digest.js +190 -0
  301. package/dist/tools/round-digest.js.map +1 -0
  302. package/dist/tools/script-census.d.ts +12 -0
  303. package/dist/tools/script-census.d.ts.map +1 -0
  304. package/dist/tools/script-census.js +182 -0
  305. package/dist/tools/script-census.js.map +1 -0
  306. package/dist/tools/search-nodes.d.ts.map +1 -1
  307. package/dist/tools/search-nodes.js +5 -1
  308. package/dist/tools/search-nodes.js.map +1 -1
  309. package/dist/tools/search-strings.d.ts.map +1 -1
  310. package/dist/tools/search-strings.js +53 -7
  311. package/dist/tools/search-strings.js.map +1 -1
  312. package/dist/tools/sequence-analysis.d.ts +53 -0
  313. package/dist/tools/sequence-analysis.d.ts.map +1 -1
  314. package/dist/tools/sequence-analysis.js +313 -89
  315. package/dist/tools/sequence-analysis.js.map +1 -1
  316. package/dist/tools/server-status.d.ts.map +1 -1
  317. package/dist/tools/server-status.js +4 -1
  318. package/dist/tools/server-status.js.map +1 -1
  319. package/dist/tools/settle-check.d.ts +12 -0
  320. package/dist/tools/settle-check.d.ts.map +1 -0
  321. package/dist/tools/settle-check.js +188 -0
  322. package/dist/tools/settle-check.js.map +1 -0
  323. package/dist/tools/shape-histogram.d.ts.map +1 -1
  324. package/dist/tools/shape-histogram.js +9 -2
  325. package/dist/tools/shape-histogram.js.map +1 -1
  326. package/dist/tools/sliced-strings.d.ts.map +1 -1
  327. package/dist/tools/sliced-strings.js +2 -1
  328. package/dist/tools/sliced-strings.js.map +1 -1
  329. package/dist/tools/snapshots.d.ts.map +1 -1
  330. package/dist/tools/snapshots.js +24 -6
  331. package/dist/tools/snapshots.js.map +1 -1
  332. package/dist/tools/sparse-elements.d.ts +12 -0
  333. package/dist/tools/sparse-elements.d.ts.map +1 -0
  334. package/dist/tools/sparse-elements.js +215 -0
  335. package/dist/tools/sparse-elements.js.map +1 -0
  336. package/dist/tools/stale-collections.d.ts +17 -0
  337. package/dist/tools/stale-collections.d.ts.map +1 -1
  338. package/dist/tools/stale-collections.js +28 -0
  339. package/dist/tools/stale-collections.js.map +1 -1
  340. package/dist/tools/string-patterns.d.ts.map +1 -1
  341. package/dist/tools/string-patterns.js +7 -5
  342. package/dist/tools/string-patterns.js.map +1 -1
  343. package/dist/tools/tools-index.d.ts +12 -0
  344. package/dist/tools/tools-index.d.ts.map +1 -0
  345. package/dist/tools/tools-index.js +492 -0
  346. package/dist/tools/tools-index.js.map +1 -0
  347. package/dist/tools/trace-all.d.ts +42 -0
  348. package/dist/tools/trace-all.d.ts.map +1 -0
  349. package/dist/tools/trace-all.js +263 -0
  350. package/dist/tools/trace-all.js.map +1 -0
  351. package/dist/tools/unit-cost.d.ts +12 -0
  352. package/dist/tools/unit-cost.d.ts.map +1 -0
  353. package/dist/tools/unit-cost.js +219 -0
  354. package/dist/tools/unit-cost.js.map +1 -0
  355. package/dist/tools/verify-fix.d.ts +23 -0
  356. package/dist/tools/verify-fix.d.ts.map +1 -0
  357. package/dist/tools/verify-fix.js +219 -0
  358. package/dist/tools/verify-fix.js.map +1 -0
  359. package/dist/tools/weakmap-entries.d.ts.map +1 -1
  360. package/dist/tools/weakmap-entries.js +6 -1
  361. package/dist/tools/weakmap-entries.js.map +1 -1
  362. package/dist/tools/weakref-census.d.ts +15 -0
  363. package/dist/tools/weakref-census.d.ts.map +1 -0
  364. package/dist/tools/weakref-census.js +147 -0
  365. package/dist/tools/weakref-census.js.map +1 -0
  366. package/dist/tools/what-if.d.ts +12 -0
  367. package/dist/tools/what-if.d.ts.map +1 -0
  368. package/dist/tools/what-if.js +285 -0
  369. package/dist/tools/what-if.js.map +1 -0
  370. package/dist/utils.d.ts +238 -3
  371. package/dist/utils.d.ts.map +1 -1
  372. package/dist/utils.js +507 -15
  373. package/dist/utils.js.map +1 -1
  374. package/package.json +7 -6
@@ -7,9 +7,12 @@
7
7
  * @format
8
8
  * @oncall memory_lab
9
9
  */
10
+ import memlabCore from '@memlab/core';
11
+ const { utils, NumericSet } = memlabCore;
10
12
  import { z } from 'zod';
11
13
  import { getSnapshot, getSnapshotMetadata } from '../heap-state.js';
12
- import { formatBytes, markdownTable, truncateNodeName, errorResult, toolResult, } from '../utils.js';
14
+ import { describeSkipped, formatBytes, formatNumber, markdownTable, truncateNodeName, errorResult, toolResult, } from '../utils.js';
15
+ import { isReactRefreshSignatureShape, REACT_REFRESH_REGISTRY_EDGE_NAMES, REACT_REFRESH_SIGNATURE_GATE_PROP, } from '../artifact-classes.js';
13
16
  // Globals installed by browser dev tools / extensions. Anything retained ONLY
14
17
  // through one of these would be garbage-collected in production — it's a
15
18
  // profiling artifact, not a real leak (Feedback round 2 §6).
@@ -24,7 +27,195 @@ const DEV_GLOBAL_EDGE_NAMES = new Set([
24
27
  '__RECOIL_DEVTOOLS_EXTENSION__',
25
28
  'Debug', // window.Debug — common debugging handle (e.g. WhatsApp Web)
26
29
  ]);
30
+ // React Fast Refresh (react-refresh) bookkeeping, installed on the global by the
31
+ // DEV-only runtime. `$RefreshSig$` / `$RefreshReg$` close over the refresh
32
+ // runtime's module-scope registries — `allFamiliesByID`, `allSignaturesByType`,
33
+ // and the family/signature maps behind them — which hold every component type
34
+ // and hook signature the page has ever compiled, plus the fibers and DOM those
35
+ // types transitively reach.
36
+ //
37
+ // Neither the globals nor the registries exist in a production build, but the
38
+ // chain roots at the real Window (not at a devtools hook or the inspector), so
39
+ // every earlier check classified the whole family `production`. Measured cost of
40
+ // getting this wrong: on one WA Web round ~93 MB of a 137.7 MB "detached DOM
41
+ // leak" was Fast Refresh retention, and a later round reported a 16,375-entry /
42
+ // 1.2 MB `allFamiliesByID` Map as a production cache-like collection. Both were
43
+ // dev-build-only.
44
+ const REACT_REFRESH_GLOBAL_EDGE_NAMES = new Set([
45
+ '$RefreshSig$',
46
+ '$RefreshReg$',
47
+ ]);
48
+ // Detecting Fast Refresh by its GLOBALS alone is not enough, and the gap is
49
+ // large rather than marginal. `$RefreshSig$` / `$RefreshReg$` are thin wrappers;
50
+ // the registries they talk to live in the refresh runtime's module scope and are
51
+ // reachable only through compiled-code constant pools
52
+ // (`system/TrustedWeakFixedArray`, `system/InstructionStream`), so the globals
53
+ // DOMINATE almost nothing and the registries stay classified `production`.
54
+ //
55
+ // Measured on a WhatsApp Web round: dev_artifacts reported
56
+ // `React Fast Refresh registry ($RefreshSig$) x2` and attributed 1.4 KB, while
57
+ // in the same snapshot the `allSignaturesByType` WeakMap's backing table alone
58
+ // was 4,096 KB and `allFamiliesByID` held 16,367 entries / 1.2 MB. The 4 MB was
59
+ // filed as a production optimization opportunity before being withdrawn.
60
+ //
61
+ // So match the registries by CONTENT as well, independent of retainer path:
62
+ //
63
+ // 1. the registry container, by the edge name that binds it in module scope;
64
+ // 2. the per-type signature record react-refresh stores via
65
+ // `setSignature(type, key, forceReset, getCustomHooks)`.
66
+ //
67
+ // Both signatures live in `artifact-classes`, which is where every
68
+ // content-matched non-production family is registered.
69
+ /**
70
+ * True when `node` is a react-refresh signature record. Callers gate on the
71
+ * rare property first; this confirms the rest.
72
+ */
73
+ function isReactRefreshSignatureRecord(node) {
74
+ const propNames = [];
75
+ for (const edge of node.references) {
76
+ if (edge.type !== 'property')
77
+ continue;
78
+ propNames.push(String(edge.name_or_index));
79
+ }
80
+ return isReactRefreshSignatureShape(propNames);
81
+ }
82
+ // The automation/devtools *bridge* injected into the page by a CDP-driven
83
+ // harness (the browser MCP plugin, a browser-tools extension, Puppeteer helper
84
+ // bundles). This is the harness observing the app, not the app.
85
+ //
86
+ // Two independent signatures, both observed on real hunts:
87
+ // - `TOOL_DEFINITIONS`: the bridge's tool manifest, reached through its
88
+ // listener closure's scope chain. On one run it held 64 `.description`
89
+ // strings that `intern_opportunities` then reported as the single largest
90
+ // interning opportunity in the heap.
91
+ // - bridge entry points by name (`getDevToolBridge`,
92
+ // `BrowserToolsSuspenseInterop`), which show up as multi-MB `production`
93
+ // retainers in `dev_artifacts`' own table.
94
+ //
95
+ // Like `_debugStack` this is found by scanning edges rather than global roots,
96
+ // because the bridge is reached from ordinary app-side listeners.
97
+ const HARNESS_EDGE_NAMES = new Set(['TOOL_DEFINITIONS']);
98
+ const HARNESS_NODE_NAME_RE = /getDevToolBridge|BrowserToolsSuspenseInterop|__BROWSER_TOOLS_|__PUPPETEER_/;
27
99
  const DEV_NODE_NAME_RE = /__REACT_DEVTOOLS|DEVTOOLS_GLOBAL_HOOK|ReactDevTools/;
100
+ // Dev-tools BRIDGES are not reached through a window global, so none of the
101
+ // checks above see them: the bridge module sits in the app's own module
102
+ // registry and the objects it accumulates (stanza logs, route history, command
103
+ // registries) root at the real Window. A dev-build bridge that records one
104
+ // entry per network message accumulates for as long as the tab is open — on one
105
+ // measured session 15,582 records / 3.05 MB, still growing while idle — and was
106
+ // reported as production memory.
107
+ const DEV_BRIDGE_NODE_NAME_RE = /DevToolsBridge|DevToolBridge|__DEVTOOLS_BRIDGE__/i;
108
+ /**
109
+ * Deployment-specific artifact roots, so a repo can teach this tool about a
110
+ * dev-only global or module the OSS package cannot know about, instead of the
111
+ * family being silently counted as production memory.
112
+ *
113
+ * MEMLAB_DEV_ARTIFACT_GLOBALS=myDebugHook,__MY_DEVTOOLS__
114
+ * MEMLAB_DEV_ARTIFACT_NODE_PATTERN=MyAppDebugBridge|MyProfiler
115
+ */
116
+ function extraDevGlobalNames() {
117
+ const raw = process.env.MEMLAB_DEV_ARTIFACT_GLOBALS;
118
+ if (raw == null || raw.trim() === '')
119
+ return new Set();
120
+ return new Set(raw
121
+ .split(',')
122
+ .map(n => n.trim())
123
+ .filter(n => n.length > 0));
124
+ }
125
+ function extraDevNodePattern() {
126
+ const raw = process.env.MEMLAB_DEV_ARTIFACT_NODE_PATTERN;
127
+ if (raw == null || raw.trim() === '')
128
+ return null;
129
+ try {
130
+ return new RegExp(raw, 'i');
131
+ }
132
+ catch {
133
+ // A malformed pattern must not take the whole tool down; it is reported by
134
+ // the explain output instead.
135
+ return null;
136
+ }
137
+ }
138
+ // Blink accessibility caches. Under CDP-driven automation the a11y tree is
139
+ // materialized (and browser_take_snapshot inflates it further), so this native
140
+ // cache balloons and co-retains detached DOM — automation-inflated retention
141
+ // that is not present at that scale in a normal user session, so it should not
142
+ // be counted toward a production leak total (Feedback: WA Web hunts §A).
143
+ const AX_NODE_NAME_RE = /AXObjectCache|AXNodeObject|AXDirtyObject|AXComputedObject|blink::AX/;
144
+ // CDP / DevTools inspector retention. When DevTools — or a CDP-driven
145
+ // automation session (Puppeteer / the browser MCP) — is attached, any object
146
+ // passed to console.log (or a dev-build devConsole) is held by the inspector
147
+ // via edges on the "(Global handles)" synthetic node named ".../ DevTools
148
+ // console". Such an object, and any subtree it pins (e.g. a logged
149
+ // `{node}` keeping a detached DOM tree alive), would be garbage-collected in a
150
+ // real user session with DevTools closed, so retention ONLY through them is a
151
+ // measurement artifact, not a production leak. `dev_artifacts` previously
152
+ // missed this because it only knew about dev globals and the a11y cache, which
153
+ // caused console-retained detached DOM to be mis-reported as production-real
154
+ // (Feedback round 3 §A).
155
+ const GLOBAL_HANDLES_NODE_NAME = '(Global handles)';
156
+ const CONSOLE_HANDLE_EDGE_RE = /DevTools console/i;
157
+ // React DEV-build owner stacks. In a development build React attaches
158
+ // `_debugStack` (a captured `Error`) to fibers. That Error's `ErrorStackData`
159
+ // holds the captured stack-frame array, which holds whatever closures were on
160
+ // the stack — commonly the `batchedUpdates` closure whose scope still refers to
161
+ // the handler's `nativeEvent`, which in turn pins the DOM subtree that was being
162
+ // unmounted. The result is a detached subtree retained entirely by DEV-only
163
+ // bookkeeping.
164
+ //
165
+ // This family is invisible to the checks above because the chain is rooted at a
166
+ // LIVE DOM element's fiber, not at a dev global or the inspector — so it was
167
+ // classified `production` and read as a genuine unmount leak. It is production-
168
+ // safe (`_debugStack` does not exist in a production React build) and, because
169
+ // it hangs off whatever handler was running, it fires on ANY synthetic-click
170
+ // hammer that unmounts a subtree.
171
+ //
172
+ // Marking the Error itself as a dev root is enough: the existing dev-only
173
+ // reachability pass then attributes the whole ErrorStackData -> frames ->
174
+ // closure -> nativeEvent -> detached-subtree chain to it.
175
+ const REACT_DEBUG_STACK_EDGE_NAMES = new Set(['_debugStack', '_debugTask']);
176
+ const CATEGORY_LABEL = {
177
+ console: 'DevTools console (CDP inspector)',
178
+ a11y: 'a11y / CDP automation cache',
179
+ devGlobal: 'dev/extension global',
180
+ reactDebugStack: 'React DEV owner stack (_debugStack)',
181
+ reactFastRefresh: 'React Fast Refresh registry ($RefreshSig$)',
182
+ harness: 'automation/devtools bridge (test harness)',
183
+ };
184
+ /**
185
+ * One-line summary of the dev/automation roots present, as counts per family.
186
+ *
187
+ * The full list was printed on every call and is unbounded: each Blink a11y
188
+ * object and each bridge export is its own root, so a measured WhatsApp Web run
189
+ * spent ~1,500 tokens per call on a single comma-separated line of names, and an
190
+ * Ads Manager run repeated an entire multi-sentence React warning inside it.
191
+ * Nothing downstream keyed on the individual names — the reader needs to know
192
+ * WHICH FAMILIES are present, which is what the categories already encode.
193
+ * `show_roots: true` restores the full list for the rare case of chasing one
194
+ * specific root.
195
+ */
196
+ export function summarizeDevRoots(devRoots, showAll = false) {
197
+ if (devRoots.byId.size === 0)
198
+ return 'none';
199
+ if (showAll) {
200
+ return [...new Set(devRoots.byId.values())].join(', ');
201
+ }
202
+ const byCat = new Map();
203
+ let uncategorized = 0;
204
+ for (const id of devRoots.byId.keys()) {
205
+ const cat = devRoots.categoryById.get(id);
206
+ if (cat == null) {
207
+ uncategorized++;
208
+ continue;
209
+ }
210
+ byCat.set(cat, (byCat.get(cat) ?? 0) + 1);
211
+ }
212
+ const parts = [...byCat.entries()]
213
+ .sort((a, b) => b[1] - a[1])
214
+ .map(([cat, n]) => `${CATEGORY_LABEL[cat]} ×${n}`);
215
+ if (uncategorized > 0)
216
+ parts.push(`other ×${uncategorized}`);
217
+ return `${devRoots.byId.size} root(s) — ${parts.join(', ')}`;
218
+ }
28
219
  /**
29
220
  * Find the dev/extension "root" objects: the targets of dev-global edges on
30
221
  * the Window/global object, plus any node whose own name marks it as a
@@ -32,36 +223,332 @@ const DEV_NODE_NAME_RE = /__REACT_DEVTOOLS|DEVTOOLS_GLOBAL_HOOK|ReactDevTools/;
32
223
  */
33
224
  export function collectDevRoots(snapshot) {
34
225
  const byId = new Map();
226
+ const categoryById = new Map();
227
+ const extraGlobals = extraDevGlobalNames();
228
+ const extraNodeRe = extraDevNodePattern();
35
229
  snapshot.nodes.forEach(node => {
36
230
  if (node.id <= 3)
37
231
  return;
232
+ // Bridges and deployment-specific dev modules are matched by NODE name:
233
+ // they are not hung off a window global, so the edge-name checks below
234
+ // never see them.
235
+ if (DEV_BRIDGE_NODE_NAME_RE.test(node.name) ||
236
+ (extraNodeRe != null && extraNodeRe.test(node.name))) {
237
+ byId.set(node.id, `${node.name} (dev-tools bridge; absent in production)`);
238
+ categoryById.set(node.id, 'devGlobal');
239
+ }
38
240
  const isGlobal = node.name.startsWith('Window ') ||
39
241
  node.name === 'global' ||
40
242
  node.name === 'globalThis';
41
243
  if (isGlobal) {
42
244
  for (const edge of node.references) {
43
245
  const eName = String(edge.name_or_index);
44
- if (DEV_GLOBAL_EDGE_NAMES.has(eName) && edge.toNode.id > 3) {
246
+ if ((DEV_GLOBAL_EDGE_NAMES.has(eName) || extraGlobals.has(eName)) &&
247
+ edge.toNode.id > 3) {
45
248
  byId.set(edge.toNode.id, eName);
249
+ categoryById.set(edge.toNode.id, 'devGlobal');
250
+ }
251
+ if (REACT_REFRESH_GLOBAL_EDGE_NAMES.has(eName) && edge.toNode.id > 3) {
252
+ byId.set(edge.toNode.id, `${eName} (React Fast Refresh; absent in production builds)`);
253
+ categoryById.set(edge.toNode.id, 'reactFastRefresh');
254
+ }
255
+ }
256
+ }
257
+ // Objects held only by the attached inspector's console (CDP global
258
+ // handles named ".../ DevTools console"). Mark just those targets — NOT the
259
+ // whole (Global handles) node, which also holds legitimate native/global
260
+ // handles for real app objects.
261
+ if (node.name === GLOBAL_HANDLES_NODE_NAME) {
262
+ for (const edge of node.references) {
263
+ if (CONSOLE_HANDLE_EDGE_RE.test(String(edge.name_or_index)) &&
264
+ edge.toNode.id > 3) {
265
+ byId.set(edge.toNode.id, 'DevTools console (CDP inspector-retained; GC-eligible with DevTools closed)');
266
+ categoryById.set(edge.toNode.id, 'console');
46
267
  }
47
268
  }
48
269
  }
270
+ // React DEV owner stacks hang off fibers, which are reachable from LIVE
271
+ // DOM nodes — so they are found by scanning edges, not global roots. The
272
+ // automation bridge is reached the same way, from app-side listeners.
273
+ for (const edge of node.references) {
274
+ const eName = String(edge.name_or_index);
275
+ if (REACT_DEBUG_STACK_EDGE_NAMES.has(eName) && edge.toNode.id > 3) {
276
+ byId.set(edge.toNode.id, 'React DEV owner stack (_debugStack; absent in production builds)');
277
+ categoryById.set(edge.toNode.id, 'reactDebugStack');
278
+ }
279
+ if (HARNESS_EDGE_NAMES.has(eName) && edge.toNode.id > 3) {
280
+ byId.set(edge.toNode.id, `${eName} (automation/devtools bridge injected by the harness)`);
281
+ categoryById.set(edge.toNode.id, 'harness');
282
+ }
283
+ // Fast Refresh registries bound in the refresh runtime's module scope.
284
+ // Matched by edge name rather than by retainer path, because the path
285
+ // runs through compiled-code constant pools that no dev global dominates.
286
+ if (REACT_REFRESH_REGISTRY_EDGE_NAMES.has(eName) && edge.toNode.id > 3) {
287
+ byId.set(edge.toNode.id, `${eName} (React Fast Refresh registry; absent in production builds)`);
288
+ categoryById.set(edge.toNode.id, 'reactFastRefresh');
289
+ }
290
+ // The signature record itself, so entries survive being read out of a
291
+ // WeakMap whose table is not itself dominated by the registry edge.
292
+ if (eName === REACT_REFRESH_SIGNATURE_GATE_PROP &&
293
+ node.id > 3 &&
294
+ isReactRefreshSignatureRecord(node)) {
295
+ byId.set(node.id, 'react-refresh signature record ({forceReset, ownKey, fullKey, getCustomHooks}; absent in production builds)');
296
+ categoryById.set(node.id, 'reactFastRefresh');
297
+ }
298
+ }
49
299
  if (DEV_NODE_NAME_RE.test(node.name)) {
50
300
  byId.set(node.id, node.name);
301
+ categoryById.set(node.id, 'devGlobal');
302
+ }
303
+ if (AX_NODE_NAME_RE.test(node.name)) {
304
+ byId.set(node.id, `${node.name} (a11y/CDP automation cache)`);
305
+ categoryById.set(node.id, 'a11y');
306
+ }
307
+ if (HARNESS_NODE_NAME_RE.test(node.name)) {
308
+ byId.set(node.id, `${node.name} (automation/devtools bridge)`);
309
+ categoryById.set(node.id, 'harness');
310
+ }
311
+ });
312
+ return { byId, categoryById };
313
+ }
314
+ /**
315
+ * Size each Fast Refresh registry container together with its hash-table
316
+ * backing store, and report the table's OCCUPANCY.
317
+ *
318
+ * Occupancy is the part that is not obvious and not otherwise reported. V8 never
319
+ * shrinks an `EphemeronHashTable`, so a WeakMap that ever peaked large keeps
320
+ * that capacity for the lifetime of the isolate. Measured across one sweep, the
321
+ * same `allSignaturesByType` table read:
322
+ *
323
+ * 2,097,172 B / 262,144 slots / 2,989 live entries = 1.14% occupancy
324
+ * 8,388,628 B / 1,048,576 slots / 7,564 live entries = 0.72% occupancy
325
+ *
326
+ * i.e. it quadrupled while the live entry count only 2.5x'd. Printing bytes
327
+ * alone invites "the registry grew"; printing occupancy says "the table is
328
+ * stale capacity", which is the true statement and a different fix.
329
+ *
330
+ * A hash table's slot count is derived from the backing store's byte size
331
+ * (8 bytes per slot on 64-bit V8) rather than from `edge_count`, because
332
+ * `edge_count` counts only the non-hole entries — which is the numerator, not
333
+ * the denominator.
334
+ */
335
+ function describeReactRefreshRegistries(snapshot, devRoots) {
336
+ const out = [];
337
+ const seen = new Set();
338
+ snapshot.nodes.forEach((node) => {
339
+ if (node.id <= 3)
340
+ return;
341
+ for (const edge of node.references) {
342
+ const eName = String(edge.name_or_index);
343
+ if (!REACT_REFRESH_REGISTRY_EDGE_NAMES.has(eName))
344
+ continue;
345
+ const container = edge.toNode;
346
+ if (container == null || container.id <= 3 || seen.has(container.id)) {
347
+ continue;
348
+ }
349
+ seen.add(container.id);
350
+ // The hash table hanging off the Map/Set/WeakMap.
351
+ let tableBytes = 0;
352
+ let liveSlots = 0;
353
+ for (const e2 of container.references) {
354
+ const t = e2.toNode;
355
+ if (t == null || t.type !== 'array')
356
+ continue;
357
+ if (t.self_size > tableBytes) {
358
+ tableBytes = t.self_size;
359
+ liveSlots = t.edge_count;
360
+ }
361
+ }
362
+ const capacity = tableBytes > 0 ? Math.round(tableBytes / 8) : 0;
363
+ const occupancy = capacity > 0 ? ((liveSlots / capacity) * 100).toFixed(2) : null;
364
+ out.push(`\`${eName}\` (${container.name}) — table ${formatBytes(tableBytes)}` +
365
+ (capacity > 0
366
+ ? `, ~${formatNumber(capacity)} slots, ${formatNumber(liveSlots)} live` +
367
+ (occupancy != null ? ` (**${occupancy}% occupancy**)` : '')
368
+ : '') +
369
+ (occupancy != null && Number(occupancy) < 25
370
+ ? ' — mostly STALE CAPACITY: V8 never shrinks an EphemeronHashTable, so this is a high-water mark, not live data'
371
+ : ''));
51
372
  }
52
373
  });
53
- return { byId };
374
+ if (out.length > 0 && devRoots.byId.size > 0) {
375
+ out.push('_These are dev-build only. If an `(unnamed array)` is one of the largest growers in a `memlab_leak_report` on this capture, check these byte figures before attributing it to the app._');
376
+ }
377
+ return out;
54
378
  }
55
379
  /**
56
- * Classify whether a node is retained ONLY via a dev/extension global. We walk
57
- * the dominator chain upward: if a dev root *dominates* the node, then every
58
- * path from a GC root to the node passes through that dev object, so it would
59
- * be collected in production. Using the dominator (not the shortest retainer)
60
- * makes the "only via" claim rigorous.
380
+ * Reachability from GC roots with every dev/automation root treated as a sink
381
+ * (its outgoing edges are not followed). Returns a bitmap indexed by
382
+ * `node.nodeIndex`: 1 = still reachable from a real GC root without passing
383
+ * through a dev root (production-reachable), 0 = reachable ONLY through one or
384
+ * more dev roots (a dev/automation-only artifact).
385
+ *
386
+ * This is stricter and more complete than a dominator walk: when an object is
387
+ * co-retained by SEVERAL different dev roots (e.g. the DevTools console AND the
388
+ * a11y cache both point at the same detached-DOM subtree), no single dev root
389
+ * dominates it, so a dominator walk misses it — but it is still dev-only because
390
+ * every path to it passes through some dev root. This pass catches that case.
61
391
  */
62
- export function classifyDevOnly(node, devRoots, maxWalk = 1000) {
392
+ export function computeReachableWithoutDevRoots(snapshot, devRoots) {
393
+ const { byId } = devRoots;
394
+ const reached = new Uint8Array(snapshot.nodes.length);
395
+ const stack = [];
396
+ // Seed from the synthetic GC roots (the "(GC roots)" super-root and its
397
+ // synthetic children such as "(Global handles)"): all real objects are
398
+ // reachable from these. A dev root that is itself synthetic is seeded but not
399
+ // expanded.
400
+ snapshot.nodes.forEach(node => {
401
+ if (node.type !== 'synthetic' && node.id > 3)
402
+ return;
403
+ if (reached[node.nodeIndex])
404
+ return;
405
+ reached[node.nodeIndex] = 1;
406
+ if (!byId.has(node.id))
407
+ stack.push(node);
408
+ });
409
+ while (stack.length > 0) {
410
+ const node = stack.pop();
411
+ node.forEachReference((edge) => {
412
+ const to = edge.toNode;
413
+ if (reached[to.nodeIndex])
414
+ return;
415
+ reached[to.nodeIndex] = 1;
416
+ // Mark the dev root reached (it is held by a real global handle) but do
417
+ // NOT follow its edges — anything reachable only through it is dev-only.
418
+ if (!byId.has(to.id))
419
+ stack.push(to);
420
+ });
421
+ }
422
+ return reached;
423
+ }
424
+ const CATEGORY_BIT = {
425
+ console: 1,
426
+ a11y: 2,
427
+ devGlobal: 4,
428
+ reactDebugStack: 8,
429
+ reactFastRefresh: 16,
430
+ harness: 32,
431
+ };
432
+ /**
433
+ * Whole-heap total of everything retained ONLY via dev/automation roots, plus a
434
+ * per-source breakdown.
435
+ *
436
+ * This is deliberately independent of any display threshold. The previous
437
+ * implementation accumulated the total inside the same loop that filtered
438
+ * candidates by `min_retained_size`, so the headline number only counted
439
+ * objects above the cutoff. With the 512 KB default that made a heap holding
440
+ * thousands of small console-retained objects report **0 B dev-only** — read by
441
+ * a caller as "this is production-real". Whole classes of artifact (per-event
442
+ * log strings, one logged object per interaction) are individually tiny and
443
+ * collectively large, which is exactly the case that matters.
444
+ *
445
+ * Attribution is a single BFS over the dev-only subgraph, seeded from each dev
446
+ * root's dev-only successors and OR-ing a category bit as it goes, so a subtree
447
+ * co-retained by several artifact families is counted under each.
448
+ */
449
+ export function summarizeDevOnly(snapshot, devRoots, reached) {
450
+ const { byId, categoryById } = devRoots;
451
+ const mask = new Uint8Array(snapshot.nodes.length);
452
+ const stack = [];
453
+ const devOnlyIds = new NumericSet();
454
+ let nodes = 0;
455
+ // Seed: dev-only successors of each dev root, tagged with that root's family.
456
+ snapshot.nodes.forEach(node => {
457
+ const cat = categoryById.get(node.id);
458
+ if (cat == null || !byId.has(node.id))
459
+ return;
460
+ const bit = CATEGORY_BIT[cat];
461
+ node.forEachReference((edge) => {
462
+ const to = edge.toNode;
463
+ if (reached[to.nodeIndex])
464
+ return; // production-reachable, not an artifact
465
+ if ((mask[to.nodeIndex] & bit) !== 0)
466
+ return;
467
+ mask[to.nodeIndex] |= bit;
468
+ stack.push(to);
469
+ });
470
+ });
471
+ while (stack.length > 0) {
472
+ const node = stack.pop();
473
+ const bits = mask[node.nodeIndex];
474
+ node.forEachReference((edge) => {
475
+ const to = edge.toNode;
476
+ if (reached[to.nodeIndex])
477
+ return;
478
+ if ((mask[to.nodeIndex] & bits) === bits)
479
+ return; // nothing new to add
480
+ mask[to.nodeIndex] |= bits;
481
+ stack.push(to);
482
+ });
483
+ }
484
+ const byCategory = new Map();
485
+ snapshot.nodes.forEach(node => {
486
+ if (node.id <= 3)
487
+ return;
488
+ if (reached[node.nodeIndex])
489
+ return;
490
+ nodes++;
491
+ devOnlyIds.add(node.id);
492
+ const bits = mask[node.nodeIndex];
493
+ for (const cat of Object.keys(CATEGORY_BIT)) {
494
+ if ((bits & CATEGORY_BIT[cat]) === 0)
495
+ continue;
496
+ const cur = byCategory.get(cat) ?? { nodes: 0, selfBytes: 0 };
497
+ cur.nodes++;
498
+ cur.selfBytes += node.self_size;
499
+ byCategory.set(cat, cur);
500
+ }
501
+ });
502
+ // Dominator-deduplicated so nested artifacts are not double-counted.
503
+ const retained = nodes === 0
504
+ ? 0
505
+ : utils.aggregateDominatorMetrics(devOnlyIds, snapshot, () => true, (node) => node.retainedSize);
506
+ return { nodes, retained, byCategory };
507
+ }
508
+ /**
509
+ * Find a dev-root name on the node's shortest retainer path, for the "via"
510
+ * label. Best-effort: the shortest path usually runs through the dominant
511
+ * retainer; falls back to a generic label when it does not.
512
+ */
513
+ function findDevRootVia(node, devRoots) {
514
+ let cur = node;
515
+ const seen = new Set();
516
+ let steps = 0;
517
+ while (cur && cur.hasPathEdge && steps < 1000) {
518
+ const edge = cur.pathEdge;
519
+ if (!edge)
520
+ break;
521
+ const from = edge.fromNode;
522
+ const via = devRoots.byId.get(from.id);
523
+ if (via != null)
524
+ return via;
525
+ if (seen.has(from.id))
526
+ break;
527
+ seen.add(from.id);
528
+ cur = from;
529
+ steps++;
530
+ }
531
+ return null;
532
+ }
533
+ /**
534
+ * Classify whether a node is retained ONLY via dev/automation roots. Prefer the
535
+ * reachability bitmap from `computeReachableWithoutDevRoots` (rigorous and
536
+ * complete, including multi-dev-root co-retention). Without a bitmap, fall back
537
+ * to a dominator walk: if a dev root *dominates* the node, every path to it
538
+ * passes through that dev object — conservative, catches only single-dev-root
539
+ * retention.
540
+ */
541
+ export function classifyDevOnly(node, devRoots, reached, maxWalk = 1000) {
63
542
  if (devRoots.byId.size === 0)
64
543
  return { devOnly: false, via: null };
544
+ if (reached != null) {
545
+ if (reached[node.nodeIndex])
546
+ return { devOnly: false, via: null };
547
+ return {
548
+ devOnly: true,
549
+ via: findDevRootVia(node, devRoots) ?? 'dev/automation roots',
550
+ };
551
+ }
65
552
  let cur = node.dominatorNode ?? null;
66
553
  let steps = 0;
67
554
  while (cur && steps < maxWalk) {
@@ -75,13 +562,82 @@ export function classifyDevOnly(node, devRoots, maxWalk = 1000) {
75
562
  }
76
563
  return { devOnly: false, via: null };
77
564
  }
565
+ /**
566
+ * The families this tool knows how to look for, and whether any root for each
567
+ * was found. Printed on demand because the failure mode of a hardcoded family
568
+ * list is silence: a family nobody thought of contributes 0 bytes and reads
569
+ * exactly like "there are no artifacts in this snapshot".
570
+ */
571
+ function explainCoverage(devRoots) {
572
+ const seen = new Map();
573
+ for (const cat of devRoots.categoryById.values()) {
574
+ seen.set(cat, (seen.get(cat) ?? 0) + 1);
575
+ }
576
+ const families = [
577
+ [
578
+ 'devGlobal',
579
+ 'Dev/extension globals + dev-tools bridges',
580
+ [...DEV_GLOBAL_EDGE_NAMES].join(', ') +
581
+ `; node names matching ${DEV_BRIDGE_NODE_NAME_RE}`,
582
+ ],
583
+ [
584
+ 'reactFastRefresh',
585
+ 'React Fast Refresh registries',
586
+ [...REACT_REFRESH_GLOBAL_EDGE_NAMES].join(', '),
587
+ ],
588
+ [
589
+ 'a11y',
590
+ 'Blink accessibility caches (CDP-inflated)',
591
+ String(AX_NODE_NAME_RE),
592
+ ],
593
+ [
594
+ 'console',
595
+ 'Inspector console retention',
596
+ `${GLOBAL_HANDLES_NODE_NAME} edges matching ${CONSOLE_HANDLE_EDGE_RE}`,
597
+ ],
598
+ [
599
+ 'harness',
600
+ 'Automation/test-harness bundle',
601
+ [...HARNESS_EDGE_NAMES].join(', ') + `; ${HARNESS_NODE_NAME_RE}`,
602
+ ],
603
+ [
604
+ 'reactDebugStack',
605
+ 'React DEV owner stacks',
606
+ [...REACT_DEBUG_STACK_EDGE_NAMES].join(', '),
607
+ ],
608
+ ];
609
+ const rows = families.map(([cat, name, rule]) => [
610
+ name,
611
+ seen.has(cat) ? `${seen.get(cat)} root(s)` : 'none found',
612
+ rule.length > 90 ? rule.slice(0, 87) + '…' : rule,
613
+ ]);
614
+ const extraGlobals = [...extraDevGlobalNames()];
615
+ const extraPattern = process.env.MEMLAB_DEV_ARTIFACT_NODE_PATTERN;
616
+ const lines = [
617
+ '### Families checked',
618
+ '',
619
+ markdownTable(['Family', 'Found here', 'Match rule'], rows),
620
+ '',
621
+ extraGlobals.length > 0 || extraPattern
622
+ ? `Deployment additions in effect: ${extraGlobals.length > 0 ? `globals [${extraGlobals.join(', ')}]` : ''}${extraGlobals.length > 0 && extraPattern ? '; ' : ''}${extraPattern ? `node pattern /${extraPattern}/` : ''}.`
623
+ : '_No deployment additions configured. If a family below reads "none found" but you know the surface has one, teach it: `MEMLAB_DEV_ARTIFACT_GLOBALS=name1,name2` for a window global, `MEMLAB_DEV_ARTIFACT_NODE_PATTERN=regex` for a module/bridge reached through the app\'s own registry._',
624
+ '',
625
+ '_"none found" means no ROOT for that family exists in this capture — it does not mean the family was ruled out for the objects you are looking at._',
626
+ ];
627
+ return lines.join('\n');
628
+ }
78
629
  export function registerDevArtifacts(server) {
79
- server.tool('memlab_dev_artifacts', 'Classify large retainers as production-relevant vs. dev-only (browser snapshots). Flags any object retained ONLY through a dev/extension global (__REACT_DEVTOOLS_GLOBAL_HOOK__, __REDUX_DEVTOOLS_EXTENSION__, window.Debug, …) — these are profiling artifacts that would be garbage-collected in production and should not be counted as leaks. Reports total bytes attributable to dev artifacts so "241 MB leak!" headlines that are really DevTools retention get caught.', {
630
+ server.tool('memlab_dev_artifacts', 'Classify large retainers as production-relevant vs. dev/automation-only (browser snapshots). Flags any object retained ONLY through a dev/extension global (__REACT_DEVTOOLS_GLOBAL_HOOK__, __REDUX_DEVTOOLS_EXTENSION__, window.Debug, …), through a Blink accessibility cache (AXObjectCacheImpl/AXNodeObject/AXDirtyObject) inflated by CDP-driven automation building the a11y tree, OR through the attached inspector\'s console objects passed to console.log / a dev-build devConsole are held by DevTools/CDP via "(Global handles) / DevTools console" edges (and can pin a whole detached DOM subtree, e.g. a logged {node} ref), which are GC\'d once DevTools/automation detaches. All three are measurement artifacts that should not be counted as production leaks. Reports total bytes attributable to them so "241 MB leak!" headlines that are really DevTools / a11y-cache / console retention get caught.', {
80
631
  limit: z
81
632
  .number()
82
633
  .optional()
83
634
  .default(25)
84
635
  .describe('Maximum number of objects to classify (default 25).'),
636
+ explain: z
637
+ .boolean()
638
+ .optional()
639
+ .default(false)
640
+ .describe('List every artifact family this tool checks and whether roots for it were found in this snapshot (default false). A family that is missing from the built-in set produces a silent zero, which reads as "no artifacts here" — this makes the coverage visible so a miss can be recognised and, if needed, taught with MEMLAB_DEV_ARTIFACT_GLOBALS / MEMLAB_DEV_ARTIFACT_NODE_PATTERN.'),
85
641
  min_retained_size: z
86
642
  .number()
87
643
  .optional()
@@ -92,30 +648,83 @@ export function registerDevArtifacts(server) {
92
648
  .optional()
93
649
  .default(false)
94
650
  .describe('Show only the dev-only artifacts (default false: show both, dev-only flagged).'),
95
- }, async ({ limit, min_retained_size, only_dev }) => {
651
+ show_roots: z
652
+ .boolean()
653
+ .optional()
654
+ .default(false)
655
+ .describe('List every dev/automation root by name instead of summarizing them as counts per family (default false). The full list is unbounded — one root per a11y object and per bridge export — and cost ~1,500 tokens per call on a measured run. Use it only when chasing one specific root.'),
656
+ summary_only: z
657
+ .boolean()
658
+ .optional()
659
+ .default(true)
660
+ .describe('Headline totals and the per-source breakdown ONLY (DEFAULT TRUE). This is the largest single output of any tool here and the ranked table is re-read for a number that is already in the headline; pass false for the per-object classification table and the below-threshold aggregation'),
661
+ }, async ({ limit, min_retained_size, only_dev, show_roots, explain, summary_only, }) => {
96
662
  try {
97
663
  const snapshot = getSnapshot();
98
664
  const meta = getSnapshotMetadata();
99
665
  const totalSize = meta?.totalSize ?? 0;
100
666
  const devRoots = collectDevRoots(snapshot);
101
667
  if (devRoots.byId.size === 0) {
102
- return toolResult('No dev/extension globals (__REACT_DEVTOOLS_GLOBAL_HOOK__, __REDUX_DEVTOOLS_EXTENSION__, window.Debug, …) found in this snapshot. ' +
103
- 'Either this is a production/clean capture, or none were installed — large retainers here are NOT dev artifacts.');
668
+ return toolResult('No dev/extension globals (__REACT_DEVTOOLS_GLOBAL_HOOK__, __REDUX_DEVTOOLS_EXTENSION__, window.Debug, …) or accessibility caches (AXObjectCacheImpl, …) found in this snapshot. ' +
669
+ 'Either this is a production/clean capture, or none were installed — large retainers here are NOT dev/automation artifacts.' +
670
+ (explain ? '\n\n' + explainCoverage(devRoots) : ''));
104
671
  }
672
+ // One reachability pass (dev roots as sinks) drives every
673
+ // classification below — catches multi-dev-root co-retention that a
674
+ // per-node dominator walk misses.
675
+ const reached = computeReachableWithoutDevRoots(snapshot, devRoots);
105
676
  const cands = [];
106
- let devOnlyBytes = 0;
677
+ // Whole-heap total, independent of `min_retained_size` (see
678
+ // summarizeDevOnly): the headline number must never be filtered by the
679
+ // display threshold.
680
+ const totals = summarizeDevOnly(snapshot, devRoots, reached);
681
+ let examined = 0;
682
+ let skippedBySize = 0;
683
+ // Dev-only objects that fall UNDER the display threshold, aggregated by
684
+ // class. The headline total already counts them, but the table did not
685
+ // show them at any size, so the common shape of an automation artifact —
686
+ // thousands of ~10 KB console-retained records, none individually near
687
+ // the 512 KB default — was invisible: a reader saw "N MB is dev-only",
688
+ // scanned an empty-looking table, and concluded there was nothing to
689
+ // discount. Grouping restores them without lowering the threshold.
690
+ const belowByClass = new Map();
691
+ let belowDevOnlyCount = 0;
692
+ let belowDevOnlyBytes = 0;
107
693
  snapshot.nodes.forEach(node => {
108
694
  if (node.id <= 3)
109
695
  return;
110
- if (node.retainedSize < min_retained_size)
111
- return;
112
696
  if (node.type !== 'object' &&
113
697
  node.type !== 'closure' &&
114
698
  node.type !== 'array')
115
699
  return;
116
- const { devOnly, via } = classifyDevOnly(node, devRoots);
117
- if (devOnly)
118
- devOnlyBytes += node.retainedSize;
700
+ examined++;
701
+ if (node.retainedSize < min_retained_size) {
702
+ skippedBySize++;
703
+ // Test reachability directly rather than via classifyDevOnly: the
704
+ // latter also resolves the "via" label by walking retainers, which
705
+ // is far too expensive to pay per node on the below-threshold tail.
706
+ if (reached[node.nodeIndex] === 0) {
707
+ belowDevOnlyCount++;
708
+ // SELF size, not retained: retained sizes overlap wherever these
709
+ // objects nest, so summing them across a class reports more than
710
+ // exists. Self size is additive, and matches the units the
711
+ // "By source" breakdown above already uses.
712
+ belowDevOnlyBytes += node.self_size;
713
+ const key = node.name.length > 0
714
+ ? truncateNodeName(node.name, node.type, node.self_size, 40)
715
+ : `(unnamed ${node.type})`;
716
+ const e = belowByClass.get(key);
717
+ if (e) {
718
+ e.count++;
719
+ e.bytes += node.self_size;
720
+ }
721
+ else {
722
+ belowByClass.set(key, { count: 1, bytes: node.self_size });
723
+ }
724
+ }
725
+ return;
726
+ }
727
+ const { devOnly, via } = classifyDevOnly(node, devRoots, reached);
119
728
  if (only_dev && !devOnly)
120
729
  return;
121
730
  // keep top-N by retained size
@@ -129,16 +738,48 @@ export function registerDevArtifacts(server) {
129
738
  if (cands.length > limit)
130
739
  cands.length = limit;
131
740
  });
741
+ const breakdown = [...totals.byCategory.entries()]
742
+ .sort((a, b) => b[1].selfBytes - a[1].selfBytes)
743
+ .map(([cat, v]) => `${CATEGORY_LABEL[cat]} ${formatBytes(v.selfBytes)} self across ${formatNumber(v.nodes)} objects`);
132
744
  const lines = [
133
745
  '## Dev-only artifact classification',
134
746
  '',
135
- `Dev/extension globals present: ${[...new Set(devRoots.byId.values())].join(', ')}`,
136
- `Total retained held ONLY via dev artifacts: **${formatBytes(devOnlyBytes)}**` +
747
+ `Dev/automation roots present: ${summarizeDevRoots(devRoots, show_roots)}`,
748
+ `Total retained held ONLY via dev/automation artifacts: **${formatBytes(totals.retained)}** across **${formatNumber(totals.nodes)} objects**` +
137
749
  (totalSize > 0
138
- ? ` (${Math.min(100, (devOnlyBytes / totalSize) * 100).toFixed(1)}% of heap — exclude from production leak totals)`
750
+ ? ` (${Math.min(100, (totals.retained / totalSize) * 100).toFixed(1)}% of heap — exclude from production leak totals)`
139
751
  : ''),
140
- '',
141
752
  ];
753
+ if (breakdown.length > 0) {
754
+ lines.push(`By source: ${breakdown.join(' · ')}`);
755
+ }
756
+ // Name the Fast Refresh registries and their BACKING STORES explicitly.
757
+ // The category rollup above already counts these bytes, but it counts
758
+ // them as a number, and the thing an operator actually needs is the
759
+ // sentence "the biggest `(unnamed array)` in your leak report is this".
760
+ // Without it the top grower gets hand-resolved every single round: it
761
+ // presents as an anonymous multi-MB array, `leak_report` can only say
762
+ // "50-70% dev-only" because the CLASS mixes Fast Refresh tables with
763
+ // real ones, and the operator ends up walking to the WeakMap by hand to
764
+ // find `{forceReset, ownKey, fullKey, getCustomHooks}` again.
765
+ const refreshTables = describeReactRefreshRegistries(snapshot, devRoots);
766
+ if (refreshTables.length > 0) {
767
+ lines.push('', '**React Fast Refresh registries (dev-only) — including backing stores:**', ...refreshTables.map(t => `- ${t}`));
768
+ }
769
+ lines.push('_This total covers the whole heap and is NOT limited by `min_retained_size` — high-count/low-size artifacts (per-event log strings, one logged object per interaction) are included._', '', "> ⚠️ **This is not the whole artifact bill.** This tool measures RETENTION-BY-A-DEV-ROOT (dev/extension globals, the inspector console, a11y caches, Fast Refresh, the automation bridge). It does NOT count **V8 JIT warmup** — `system/Code`, `InstructionStream`, `BytecodeArray`, `ProtectedFixedArray` — which is a separate and often larger bucket, and which grows simply because a hunt exercises new code paths. On one measured round this tool reported 4.8 MB / 1.5% dev-only while JIT warmup accounted for +5.7 MB of that round's 10.8 MB of growth. For the growth-side view of both, run `memlab_explain_delta` with `include_artifacts: true` against the baseline rung.", '');
770
+ if (summary_only) {
771
+ // Say what was withheld and how to get it. A tool that quietly prints
772
+ // less reads as a tool that found less.
773
+ lines.push(`_summary_only: the ${formatNumber(cands.length)}-row classification table` +
774
+ (belowDevOnlyCount > 0
775
+ ? ` and the below-threshold aggregation (${formatNumber(belowDevOnlyCount)} objects)`
776
+ : '') +
777
+ ' are withheld. Re-run with `summary_only: false` for them._');
778
+ if (explain) {
779
+ lines.push('', explainCoverage(devRoots));
780
+ }
781
+ return toolResult(lines.join('\n'));
782
+ }
142
783
  if (cands.length > 0) {
143
784
  const headers = ['ID', 'Name', 'Type', 'Retained', 'Classification'];
144
785
  const rightCols = new Set([3]);
@@ -154,7 +795,27 @@ export function registerDevArtifacts(server) {
154
795
  else {
155
796
  lines.push('No objects matched the size threshold.');
156
797
  }
157
- lines.push('', '_"dev-only" = the object\'s dominator chain passes through a dev/extension global, so every retainer path goes through it and it would be GC\'d in production. Verify with `memlab_retainer_trace`._');
798
+ lines.push('', describeSkipped(examined, skippedBySize, `min_retained_size (${formatBytes(min_retained_size)})`));
799
+ // Below-threshold dev-only artifacts, grouped so a large aggregate made
800
+ // of small objects is visible in the table rather than only in the
801
+ // headline total.
802
+ if (belowDevOnlyCount > 0) {
803
+ const top = [...belowByClass.entries()]
804
+ .sort((a, b) => b[1].bytes - a[1].bytes)
805
+ .slice(0, 8);
806
+ lines.push('', `### Below \`min_retained_size\` — dev-only, aggregated by class`, '', `**${formatNumber(belowDevOnlyCount)} dev-only object(s) totalling ${formatBytes(belowDevOnlyBytes)} of self size** are individually under the ${formatBytes(min_retained_size)} threshold, so none appears above. This is the usual shape of an automation artifact: many small records, no single large one.`, '', markdownTable(['Class', 'Count', 'Self (sum)'], top.map(([name, v]) => [
807
+ name,
808
+ formatNumber(v.count),
809
+ formatBytes(v.bytes),
810
+ ]), new Set([1, 2])));
811
+ if (belowByClass.size > top.length) {
812
+ lines.push('', `_… +${formatNumber(belowByClass.size - top.length)} more class(es). Lower \`min_retained_size\` to list individual objects._`);
813
+ }
814
+ }
815
+ lines.push('', '_"dev-only" = the object\'s dominator chain passes through a dev/extension global, a Blink a11y cache, or an inspector "DevTools console" global handle, so every retainer path goes through it. Dev-global-retained objects would be GC\'d in production; a11y-cache and DevTools-console retention are automation/inspector-inflated (the a11y tree is materialized by CDP; console-logged objects are held by the attached inspector) and not present in a normal user session with DevTools closed. Either way, discount from production leak totals. Verify with `memlab_retainer_trace`._');
816
+ if (explain) {
817
+ lines.push('', explainCoverage(devRoots));
818
+ }
158
819
  return toolResult(lines.join('\n'));
159
820
  }
160
821
  catch (err) {