@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
@@ -8,26 +8,206 @@
8
8
  * @oncall memory_lab
9
9
  */
10
10
  import { z } from 'zod';
11
+ import fs from 'fs';
12
+ import os from 'os';
13
+ import path from 'path';
11
14
  import vm from 'node:vm';
12
15
  import memlabCore from '@memlab/core';
13
- const { utils } = memlabCore;
14
- import { getSnapshot } from '../heap-state.js';
15
- import { errorResult, toolResult, serializeNodeSummary, serializeNodeDetail, formatBytes, formatNumber, markdownTable, isNodeWorthInspecting, filterLargestObjects, queryNodes, } from '../utils.js';
16
+ const { utils, NumericSet } = memlabCore;
17
+ import { nearestFiber, fiberComponentName } from '../react-shapes.js';
18
+ import { readElements as readElementsInfo } from '../heap-shapes.js';
19
+ import { getCurrentHandle, getSavedResult, getSnapshot, isLightSnapshot, listSavedResults, setSavedResult, getEvalScratch, getSnapshotMetadata, } from '../heap-state.js';
20
+ import { beginAnalysisBudget } from '../analysis-budget.js';
21
+ import { formatEvalHints, hintsForEval } from '../eval-hints.js';
22
+ import { abbreviateBlinkTypeName, errorResult, toolResult, serializeNodeSummary, serializeNodeDetail, formatBytes, formatNumber, markdownTable, isNodeWorthInspecting, makeNamePatternTest, filterLargestObjects, queryNodes, enumerateMapEntries, enumerateSetElements, objectContentSignature, boundedDominatorRetainedSize, } from '../utils.js';
16
23
  const MAX_OUTPUT_SIZE = 50 * 1024; // 50KB
17
24
  function truncate(str, max) {
18
25
  if (str.length <= max)
19
26
  return str;
20
- return str.slice(0, max) + '\n... [truncated, output exceeded 50KB]';
27
+ return (str.slice(0, max) +
28
+ `\n... [truncated, output exceeded ${Math.round(max / 1024)}KB]`);
29
+ }
30
+ /**
31
+ * Wall-clock budget for one eval, scaled from the loaded graph.
32
+ *
33
+ * A fixed 60 s default is right for an indexed lookup and wrong for everything
34
+ * else: a full-heap walk on a 6M-node graph takes two to four minutes, so every
35
+ * large-snapshot eval aborted on its first attempt and had to be re-issued with
36
+ * an explicit `timeout_ms`. That is a guaranteed wasted call per question, and
37
+ * the node count needed to avoid it is already known.
38
+ *
39
+ * The floor stays at 60 s so small heaps behave exactly as before.
40
+ */
41
+ export function scaledEvalTimeoutMs() {
42
+ const nodes = getSnapshotMetadata()?.nodeCount ?? 0;
43
+ return Math.max(60000, Math.ceil((nodes / 40000) * 1000));
44
+ }
45
+ /**
46
+ * Default `max_nodes`, scaled to the loaded graph.
47
+ *
48
+ * A probe that makes several passes over the heap is the normal shape — one
49
+ * pass to census classes, one to match shapes, one to size things — so the
50
+ * budget has to be a MULTIPLE of the node count, not a constant. At the old
51
+ * flat 20,000,000 a routine two-pass probe on an 8.24M-node capture aborted
52
+ * mid-walk and returned a partial value; because the result was assigned after
53
+ * the loops, what came back was `undefined` with a note, which reads as a
54
+ * legitimate empty answer.
55
+ *
56
+ * Six passes is generous on purpose: the wall-clock timeout above is the real
57
+ * guard against a runaway eval, and this budget exists to stop pathological
58
+ * traversals, not ordinary multi-pass analysis.
59
+ */
60
+ export function scaledWalkBudget() {
61
+ const nodes = getSnapshotMetadata()?.nodeCount ?? 0;
62
+ return Math.max(20000000, nodes * 6);
63
+ }
64
+ /**
65
+ * Shorten a heap result STRUCTURALLY rather than mid-string.
66
+ *
67
+ * A census result is an array of rows or a `{key: count}` map, and cutting the
68
+ * JSON at a byte offset yields unparseable output plus a re-run with a
69
+ * hand-written limit — which is what actually happened to a detached-class
70
+ * census whose `blink::HeapVectorBacking<…>` keys blew the cap. Dropping whole
71
+ * entries keeps the value valid JSON and keeps the largest rows, which are the
72
+ * ones the question was about.
73
+ *
74
+ * Blink's C++ template names are elided first: they are frequently most of the
75
+ * payload and none of the information, and `abbreviateBlinkTypeName` is the same
76
+ * elision the table renderers already use.
77
+ */
78
+ export function shrinkResult(value, maxBytes) {
79
+ const size = (v) => {
80
+ try {
81
+ return JSON.stringify(v)?.length ?? 0;
82
+ }
83
+ catch {
84
+ return String(v).length;
85
+ }
86
+ };
87
+ if (size(value) <= maxBytes) {
88
+ return { value, truncated: false, droppedEntries: 0, keptEntries: -1 };
89
+ }
90
+ // Abbreviate VALUES freely, but keys only where it cannot lose data.
91
+ //
92
+ // `abbreviateBlinkTypeName` collapses everything between the first `<` and
93
+ // the last `>`, so `blink::HeapVectorBacking<Foo>` and
94
+ // `blink::HeapVectorBacking<Bar>` abbreviate to the SAME string. On a census
95
+ // map — `{className: count}`, the exact shape this exists to shrink — the
96
+ // second entry would overwrite the first and the count would silently
97
+ // disappear before any trimming happened. So a key is only shortened when the
98
+ // shortened form is still unique within its object; otherwise the full key is
99
+ // kept, because a longer result is recoverable and a wrong one is not.
100
+ const abbreviate = (v) => {
101
+ if (typeof v === 'string')
102
+ return abbreviateBlinkTypeName(v);
103
+ if (Array.isArray(v))
104
+ return v.map(abbreviate);
105
+ if (v != null && typeof v === 'object') {
106
+ const entries = Object.entries(v);
107
+ const shortened = entries.map(([k]) => abbreviateBlinkTypeName(k));
108
+ const collides = new Set(shortened).size !== shortened.length;
109
+ const out = {};
110
+ entries.forEach(([k, val], i) => {
111
+ out[collides ? k : shortened[i]] = abbreviate(val);
112
+ });
113
+ return out;
114
+ }
115
+ return v;
116
+ };
117
+ let shrunk = abbreviate(value);
118
+ if (size(shrunk) <= maxBytes) {
119
+ return {
120
+ value: shrunk,
121
+ truncated: false,
122
+ droppedEntries: 0,
123
+ keptEntries: -1,
124
+ };
125
+ }
126
+ // Binary-search the entry count that fits, so a 40k-row result does not cost
127
+ // 40k serializations to trim.
128
+ const entriesOf = (v) => {
129
+ if (Array.isArray(v)) {
130
+ return { length: v.length, take: n => v.slice(0, n) };
131
+ }
132
+ if (v != null && typeof v === 'object') {
133
+ const pairs = Object.entries(v);
134
+ return {
135
+ length: pairs.length,
136
+ take: n => Object.fromEntries(pairs.slice(0, n)),
137
+ };
138
+ }
139
+ return null;
140
+ };
141
+ const entries = entriesOf(shrunk);
142
+ if (entries == null || entries.length === 0) {
143
+ return { value: shrunk, truncated: true, droppedEntries: 0, keptEntries: -1 };
144
+ }
145
+ let lo = 0;
146
+ let hi = entries.length;
147
+ while (lo < hi) {
148
+ const mid = Math.ceil((lo + hi) / 2);
149
+ if (size(entries.take(mid)) <= maxBytes)
150
+ lo = mid;
151
+ else
152
+ hi = mid - 1;
153
+ }
154
+ shrunk = entries.take(lo);
155
+ return {
156
+ value: shrunk,
157
+ truncated: true,
158
+ droppedEntries: entries.length - lo,
159
+ keptEntries: lo,
160
+ };
161
+ }
162
+ class BudgetExceeded extends Error {
163
+ constructor(max) {
164
+ super(`max_nodes budget of ${max} exhausted`);
165
+ this.name = 'BudgetExceeded';
166
+ }
21
167
  }
22
168
  const NODE_PROPERTY_ALIASES = {
23
- retained_size: 'retainedSize',
24
169
  referrer_count: 'numOfReferrers',
25
170
  };
171
+ /**
172
+ * Lets helpers recover the real node behind a sandbox proxy. Sandbox code holds
173
+ * proxies; the helpers it passes them to need the underlying object, both to
174
+ * avoid proxy overhead per property read and because the proxy deliberately
175
+ * refuses `retainedSize` (below) while the helpers legitimately read it.
176
+ */
177
+ const RAW_NODE = Symbol('memlabRawNode');
178
+ export function unwrapNode(node) {
179
+ if (node != null && typeof node === 'object') {
180
+ const raw = node[RAW_NODE];
181
+ if (raw != null)
182
+ return raw;
183
+ }
184
+ return node;
185
+ }
186
+ /**
187
+ * `node.retainedSize` read off a node inside eval has been observed to come
188
+ * back ~0 for every node on some loads, while the same id read through
189
+ * `snapshot.getNodeById(id).retainedSize` returns the true value. A field that
190
+ * silently yields a wrong NUMBER is worse than one that fails: every ranking
191
+ * built on it looks plausible and is wrong, and nothing in the output says so.
192
+ *
193
+ * So the sandbox refuses the read and names the working call. The docs already
194
+ * carried this as a caveat; a caveat is only as good as the reader's memory of
195
+ * it, and this class of silent-zero bug has produced published errors before.
196
+ */
197
+ const RETAINED_SIZE_PROPS = new Set(['retainedSize', 'retained_size']);
26
198
  function wrapNode(node) {
27
199
  if (node == null)
28
200
  return node;
29
201
  return new Proxy(node, {
30
202
  get(target, prop, receiver) {
203
+ if (prop === RAW_NODE)
204
+ return target;
205
+ if (typeof prop === 'string' && RETAINED_SIZE_PROPS.has(prop)) {
206
+ const id = target.id;
207
+ throw new Error(`node.${prop} is not readable inside eval — it can come back ~0 for every node on some loads, which silently corrupts any ranking built on it. ` +
208
+ `Use helpers.retainedSize(${typeof id === 'number' ? id : 'id'}) for one node, helpers.retainedSizes([ids]) for many, or helpers.aggregateRetained([ids]) for a dominator-deduped total. ` +
209
+ 'Self size (`node.self_size`) is read directly from the snapshot and IS reliable here.');
210
+ }
31
211
  if (typeof prop === 'string' && prop in NODE_PROPERTY_ALIASES) {
32
212
  return target[NODE_PROPERTY_ALIASES[prop]];
33
213
  }
@@ -78,9 +258,14 @@ function wrapEdgeIterable(iterable) {
78
258
  },
79
259
  };
80
260
  }
81
- function wrapSnapshot(snapshot) {
261
+ function wrapSnapshot(snapshot, budget) {
82
262
  return new Proxy(snapshot, {
83
263
  get(target, prop, receiver) {
264
+ // Same escape hatch the node proxy carries, for the same reason: helpers
265
+ // that take a whole snapshot walk it with the real `forEach` and read
266
+ // `retainedSize` off the nodes it yields, which this proxy refuses.
267
+ if (prop === RAW_NODE)
268
+ return target;
84
269
  if (prop === 'getNodeById') {
85
270
  const orig = target.getNodeById.bind(target);
86
271
  return (id) => wrapNode(orig(id));
@@ -92,7 +277,33 @@ function wrapSnapshot(snapshot) {
92
277
  if (nodesProp === 'forEach') {
93
278
  const origForEach = nodesTarget.forEach.bind(nodesTarget);
94
279
  return (cb) => {
95
- origForEach((node) => cb(wrapNode(node)));
280
+ const stride = budget.sampleEvery ?? 1;
281
+ const only = budget.restrictTo;
282
+ origForEach((node) => {
283
+ // `restrict_to_ids` promotes a cheap triage pass to an exact
284
+ // one without re-walking the graph blind: the same code runs,
285
+ // but only over the candidates the triage pass surfaced.
286
+ // Filtering here rather than in user code means the budget and
287
+ // the reported `nodes_visited` describe the real work.
288
+ if (only != null &&
289
+ !only.has(node.id ?? -1)) {
290
+ return undefined;
291
+ }
292
+ // Sampling is a STRIDE, not a random draw: two calls over the
293
+ // same snapshot visit the same nodes, so a follow-up question
294
+ // lands on the objects the first answer described.
295
+ const idx = budget.seen ?? 0;
296
+ budget.seen = idx + 1;
297
+ if (stride > 1 && idx % stride !== 0)
298
+ return undefined;
299
+ if (++budget.visited > budget.max) {
300
+ budget.exceeded = true;
301
+ throw new BudgetExceeded(budget.max);
302
+ }
303
+ // Returning `false` from the callback breaks the walk, so the
304
+ // callback's return value has to be passed through.
305
+ return cb(wrapNode(node));
306
+ });
96
307
  };
97
308
  }
98
309
  return Reflect.get(nodesTarget, nodesProp, nodesReceiver);
@@ -103,6 +314,261 @@ function wrapSnapshot(snapshot) {
103
314
  },
104
315
  });
105
316
  }
317
+ /**
318
+ * Identifiers whose value comes from the dominator / retained-size / shortest-
319
+ * path pass that a LIGHT load skips. On such a snapshot each of these reads
320
+ * back 0 or undefined WITHOUT failing, so eval code using them returns
321
+ * confident zeros — worse than an error. Matched textually before the code
322
+ * runs, so the refusal costs nothing.
323
+ */
324
+ const RETENTION_IDENTIFIERS = [
325
+ 'retainedSize',
326
+ 'retainedSizes',
327
+ 'retained_size',
328
+ 'aggregateRetained',
329
+ 'dominatorNode',
330
+ 'hasPathEdge',
331
+ 'pathEdge',
332
+ 'filterLargestObjects',
333
+ ];
334
+ /**
335
+ * Words that make a bare `size` mean BYTES rather than a count of things.
336
+ *
337
+ * `size` on its own is ambiguous and mostly is not bytes: `sample_size`,
338
+ * `arraySize`, `queueSize`, `mapSize` and `chainSize` all count items, and any
339
+ * of them can legitimately exceed the heap's byte total on a small snapshot —
340
+ * which would publish a confident "exceeds the whole snapshot" warning about a
341
+ * field that was never measured in bytes. The warning below is only ever a
342
+ * warning, so missing one is cheap and crying wolf is not; `size` therefore
343
+ * needs corroboration from elsewhere in the key.
344
+ */
345
+ const BYTE_CONTEXT_WORDS = new Set([
346
+ 'alloc',
347
+ 'allocated',
348
+ 'byte',
349
+ 'bytes',
350
+ 'footprint',
351
+ 'heap',
352
+ 'mem',
353
+ 'memory',
354
+ 'retained',
355
+ 'self',
356
+ 'store',
357
+ ]);
358
+ /**
359
+ * Scale factor for a key that names a byte quantity, or null if it names
360
+ * something else.
361
+ *
362
+ * The unit is read from the key's last WORD rather than from a suffix match.
363
+ * `mb`, `kb` and `gb` are two letters that also end ordinary English words, so
364
+ * a suffix test rescales `numb`, `dumb`, `thumb` and `climb` by 1024^n — enough
365
+ * to trip the implausibility warning below on a field holding no bytes at all.
366
+ * Splitting on `_` and camelCase boundaries makes `heap_mb` and `heapMB` units
367
+ * while leaving `dumb` a word.
368
+ */
369
+ function byteUnitScale(key) {
370
+ const words = key
371
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
372
+ .split(/[^A-Za-z0-9]+/)
373
+ .filter(Boolean)
374
+ .map(w => w.toLowerCase());
375
+ switch (words[words.length - 1]) {
376
+ case 'gb':
377
+ return 1024 ** 3;
378
+ case 'mb':
379
+ return 1024 ** 2;
380
+ case 'kb':
381
+ return 1024;
382
+ case 'byte':
383
+ case 'bytes':
384
+ return 1;
385
+ case 'size':
386
+ // `retainedSize` and `self_size` are the fields this guard was written
387
+ // for; `arraySize` is not. See BYTE_CONTEXT_WORDS.
388
+ return words.some(w => BYTE_CONTEXT_WORDS.has(w)) ? 1 : null;
389
+ default:
390
+ return null;
391
+ }
392
+ }
393
+ /**
394
+ * Flag a numeric field that claims more bytes than the whole heap contains.
395
+ *
396
+ * A total larger than the snapshot is not a rounding error, it is a
397
+ * double-count, and it is the easiest way for an eval to publish a confident
398
+ * wrong number. Measured case: a verification walk over 820 Map backing tables
399
+ * reported 7,005 MB of backing stores in a 425 MB heap — a Map's `table` yields
400
+ * KEYS as well as values, and the key objects were members of every one of the
401
+ * 820 tables, so each store was counted a few hundred times. It was caught only
402
+ * because 7 GB in a 425 MB heap is absurd; a 1.4x error would have shipped.
403
+ *
404
+ * A warning, never an error: summing `retainedSize` over an arbitrary set
405
+ * legitimately exceeds the heap (subtrees overlap), which is a real thing to
406
+ * measure — `helpers.aggregateRetained` exists for the deduplicated version.
407
+ */
408
+ function implausibleByteFields(value, heapBytes) {
409
+ if (heapBytes <= 0)
410
+ return [];
411
+ const hits = [];
412
+ const seen = new Set();
413
+ const visit = (node, path, depth) => {
414
+ if (hits.length >= 4 || depth > 6 || node == null)
415
+ return;
416
+ if (typeof node === 'object') {
417
+ if (seen.has(node))
418
+ return;
419
+ seen.add(node);
420
+ if (Array.isArray(node)) {
421
+ // Only a bounded prefix: a 10k-row census would otherwise be walked in
422
+ // full to warn about at most four fields.
423
+ for (let i = 0; i < Math.min(node.length, 200); i++) {
424
+ visit(node[i], `${path}[${i}]`, depth + 1);
425
+ }
426
+ return;
427
+ }
428
+ for (const [k, v] of Object.entries(node)) {
429
+ if (typeof v === 'number') {
430
+ const scale = byteUnitScale(k);
431
+ if (scale != null && v * scale > heapBytes) {
432
+ hits.push({ path: path ? `${path}.${k}` : k, bytes: v * scale });
433
+ }
434
+ }
435
+ else {
436
+ visit(v, path ? `${path}.${k}` : k, depth + 1);
437
+ }
438
+ }
439
+ }
440
+ };
441
+ visit(value, '', 0);
442
+ return hits;
443
+ }
444
+ /**
445
+ * Deepest nesting of a `.references` / `.referrers` traversal inside another
446
+ * loop, by brace depth.
447
+ *
448
+ * Textual, like the full-walk check next to it — the honest limit of a
449
+ * pre-flight, and the alternative is parsing the code. It exists because
450
+ * `max_nodes` bounds the OUTER loop and nothing warns that the inner one turns
451
+ * a 7M-node pass into a 41M-edge one. A measured probe with two nested
452
+ * traversals ran for ten minutes and returned nothing.
453
+ */
454
+ function maxTraversalNesting(code) {
455
+ const TRAVERSAL = /\.\s*(?:references|referrers)\b/g;
456
+ const LOOP = /\b(?:for\s*\(|while\s*\(|\.forEach\s*\(|\.map\s*\(|\.filter\s*\(|\.flatMap\s*\()/g;
457
+ // Character-based, not line-based. The first version tested one line at a
458
+ // time and scored `nodes.forEach(n => { for (const e of n.references) ... })`
459
+ // as depth 0, because the loop it is nested in opens on the same line — which
460
+ // is how this gets written most of the time.
461
+ /**
462
+ * Whether the construct whose opening paren ends at `from` will actually open
463
+ * a brace body.
464
+ *
465
+ * A brace-less callback — `nodes.map(n => n.name)` — never does, and counting
466
+ * it as pending made the NEXT unrelated `{` in the source (an `if` block, an
467
+ * object literal) read as that loop's body, so every traversal after it was
468
+ * reported one level deeper than it is. Scan from the paren: a `{` before the
469
+ * parens balance is a callback body, a `{` immediately after they balance is
470
+ * a `for`/`while` body, and anything else means there is no body to wait for.
471
+ */
472
+ const opensBraceBody = (from) => {
473
+ let parens = 1;
474
+ for (let i = from; i < code.length; i++) {
475
+ const c = code[i];
476
+ if (c === '{')
477
+ return true;
478
+ if (c === '(') {
479
+ parens++;
480
+ }
481
+ else if (c === ')') {
482
+ parens--;
483
+ if (parens === 0) {
484
+ let j = i + 1;
485
+ while (j < code.length && /\s/.test(code[j]))
486
+ j++;
487
+ return code[j] === '{';
488
+ }
489
+ }
490
+ }
491
+ return false;
492
+ };
493
+ const events = [];
494
+ for (const m of code.matchAll(LOOP)) {
495
+ const pos = m.index ?? 0;
496
+ // Every LOOP alternative ends with its opening paren.
497
+ if (opensBraceBody(pos + m[0].length)) {
498
+ events.push({ pos, kind: 'loop' });
499
+ }
500
+ }
501
+ for (const m of code.matchAll(TRAVERSAL)) {
502
+ events.push({ pos: m.index ?? 0, kind: 'traversal' });
503
+ }
504
+ events.sort((a, b) => a.pos - b.pos);
505
+ const loopDepths = [];
506
+ let depth = 0;
507
+ let pendingLoops = 0;
508
+ let deepest = 0;
509
+ let next = 0;
510
+ for (let i = 0; i < code.length; i++) {
511
+ while (next < events.length && events[next].pos === i) {
512
+ const ev = events[next++];
513
+ if (ev.kind === 'loop') {
514
+ pendingLoops++;
515
+ }
516
+ else {
517
+ deepest = Math.max(deepest, loopDepths.length);
518
+ }
519
+ }
520
+ const ch = code[i];
521
+ if (ch === '{') {
522
+ // The first `{` after a loop header opens that loop's body.
523
+ if (pendingLoops > 0) {
524
+ loopDepths.push(depth);
525
+ pendingLoops--;
526
+ }
527
+ depth++;
528
+ }
529
+ else if (ch === '}') {
530
+ depth--;
531
+ while (loopDepths.length > 0 &&
532
+ loopDepths[loopDepths.length - 1] >= depth) {
533
+ loopDepths.pop();
534
+ }
535
+ }
536
+ }
537
+ return deepest;
538
+ }
539
+ /**
540
+ * The helper names from the most recent eval build in this process.
541
+ *
542
+ * `mode: "lint"` runs WITHOUT a snapshot, so it cannot construct the helpers
543
+ * object to read its keys. Recording them here keeps the lint check honest:
544
+ * when the list is unknown (a fresh server that has not run an eval yet) the
545
+ * check is SKIPPED and said to be skipped, rather than reporting every real
546
+ * helper as unknown.
547
+ */
548
+ let lastKnownHelperNames = null;
549
+ /** True when `a` and `b` are within `max` single-character edits. Cheap bail-out. */
550
+ function editDistanceWithin(a, b, max) {
551
+ if (Math.abs(a.length - b.length) > max)
552
+ return false;
553
+ const prev = new Array(b.length + 1);
554
+ const cur = new Array(b.length + 1);
555
+ for (let j = 0; j <= b.length; j++)
556
+ prev[j] = j;
557
+ for (let i = 1; i <= a.length; i++) {
558
+ cur[0] = i;
559
+ let best = cur[0];
560
+ for (let j = 1; j <= b.length; j++) {
561
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
562
+ if (cur[j] < best)
563
+ best = cur[j];
564
+ }
565
+ if (best > max)
566
+ return false;
567
+ for (let j = 0; j <= b.length; j++)
568
+ prev[j] = cur[j];
569
+ }
570
+ return prev[b.length] <= max;
571
+ }
106
572
  export function registerEval(server) {
107
573
  server.tool('memlab_eval', 'Execute arbitrary JavaScript code against the loaded heap snapshot. ' +
108
574
  'The code runs in a sandboxed VM with access to `snapshot` (IHeapSnapshot), ' +
@@ -121,18 +587,24 @@ export function registerEval(server) {
121
587
  '**Get node by ID:** `snapshot.getNodeById(id)` returns IHeapNode or null.\n' +
122
588
  '**String values:** `node.toStringNode()?.stringValue` for string nodes.\n' +
123
589
  '**Caveat — retained_size is unreliable here:** inside eval, `node.retained_size`/`.retainedSize` can read back ~0 for every node on some loads. Node counts, property/edge walks, and string values ARE trustworthy. For authoritative retained sizes call `helpers.retainedSize(id)` (number) / `helpers.retainedSizes([ids])` (a `Record<id, bytes>` object, NOT an array) — they re-resolve the node on the real snapshot — or use the dedicated tools (`memlab_largest_objects`, `memlab_class_histogram`, `memlab_pinch_points`, `memlab_object_shape`).\n\n' +
124
- '**Example — inspect Map entries:**\n' +
125
- '```\nconst map = snapshot.getNodeById(12345);\nconst entries = [];\n' +
126
- 'for (const edge of map.references) {\n' +
127
- ' if (edge.name_or_index === "table") {\n' +
128
- ' for (const te of edge.toNode.references) {\n' +
129
- ' entries.push({name: te.toNode.name, type: te.toNode.type});\n' +
130
- ' }\n }\n}\nresult = entries.slice(0, 10);\n```', {
590
+ '**Example — inspect Map entries (use the helper; do NOT hand-roll the ' +
591
+ 'backing-store walk browser slots are `internal`-typed and SMI values ' +
592
+ 'leave index gaps):**\n' +
593
+ '```\nresult = helpers.mapEntries(12345, 10); // [{key, value}] briefs\n' +
594
+ '// Set: helpers.setElements(setId, 10)\n```\n' +
595
+ '**Example dedup / composition of a Relay record type:**\n' +
596
+ '```\nconst ids = helpers.byTypename("AdCreativeFeatureSpecAttachment");\n' +
597
+ 'const sigs = {};\n' +
598
+ 'for (const id of ids) { const s = helpers.shapeSignature(id); sigs[s] = (sigs[s]||0)+1; }\n' +
599
+ 'result = {count: ids.length, distinct: Object.keys(sigs).length};\n```\n' +
600
+ '**Multi-step exploration:** pass `save_as` to keep a result set server-side and `helpers.load(name)` to read it back in a later call, so intermediate id lists never have to be printed to the transcript. `mode:"list_saved"` lists them. ' +
601
+ 'Runs on a LIGHT snapshot too (counts, names, types, self sizes, string values, edge walks). Code referencing retained sizes, dominators or path edges is refused up front there rather than returning zeros. ' +
602
+ 'Pass `max_nodes` to bound a full-heap walk — on overrun the partial `result` is returned with a warning instead of failing, so a broad scan is safe to attempt. Every call reports `nodes_visited`.', {
131
603
  mode: z
132
- .enum(['eval', 'describe_env'])
604
+ .enum(['eval', 'describe_env', 'list_saved', 'lint'])
133
605
  .optional()
134
606
  .default('eval')
135
- .describe('"eval" (default) runs `code`. "describe_env" ignores `code` and returns the in-scope globals, the IHeapNode/IHeapEdge API, and the required calling conventions (`result =`, `.forEach`) so you can self-correct before running.'),
607
+ .describe('"eval" (default) runs `code`. "describe_env" ignores `code` and returns the in-scope globals, the IHeapNode/IHeapEdge API, and the required calling conventions (`result =`, `.forEach`) so you can self-correct before running — narrow it with `section` to avoid paying for all ~10 KB. "lint" syntax-checks `code`, lists the helpers it references and flags unknown ones, and estimates traversal nesting — all WITHOUT a snapshot, so a typo in a 40-line eval costs seconds instead of a 2-4 minute load. "list_saved" ignores `code` and lists the named result sets saved so far for this snapshot.'),
136
608
  code: z
137
609
  .string()
138
610
  .optional()
@@ -143,216 +615,1755 @@ export function registerEval(server) {
143
615
  'markdownTable, isNodeWorthInspecting, filterLargestObjects, queryNodes, ' +
144
616
  'groupReferrersByEdge(nodeId), groupArrayElementsByProperty(arrayNodeId, propName), ' +
145
617
  'isOrphaned(nodeId, ownershipEdgeNames[]), countUniqueTargets(arrayNodeId, propName), ' +
146
- 'retainedSize(id)->number, retainedSizes(ids[])->Record<id,bytes> (an OBJECT keyed by id, NOT an array — index it as sizes[id] or Object.values(sizes)) }), ' +
618
+ 'retainedSize(id)->number, retainedSizes(ids[])->Record<id,bytes> (an OBJECT keyed by id, NOT an array — index it as sizes[id] or Object.values(sizes)), ' +
619
+ 'mapEntries(mapId, limit?)->[{key,value}] & setElements(setId, limit?)->[brief] (correct Map/Set/WeakMap enumeration — handles browser internal-typed slots AND SMI-value gaps, so you never re-derive it wrong), ' +
620
+ 'props(nodeOrId)->{prop: scalar | {ref,name,type}} & getProp(nodeOrId, name) & shapeSignature(nodeOrId, {maxStringLen?}) (content signature for dedup checks), ' +
621
+ 'shapeKeys(nodeOrId)->Set<string> & ownProps(nodeOrId) & hasShape(nodeOrId, [names], {exact?,exclude?}) (own JS properties ONLY — USE THESE FOR SHAPE MATCHING; props() falls back to an internal-edge walk and injects length/map/__via/__note, which makes a props()-based shape test silently return zero matches), ' +
622
+ 'rootPath(nodeOrId, {maxHops?})->[{id,name,type,edge}] (GC-root path, root first — the retainer_trace walk, callable inside an eval), ' +
623
+ 'byClass(name, {type?})->ids[] & byTypename(name)->ids[] & withProp(name)->ids[] (INDEXED lookups — built once per snapshot then memoized in a session scratch, so repeated questions are index-speed not full-scan; byClass covers EVERY node type, matching memlab_find_nodes_by_class, so closures/strings/arrays/natives are found — pass {type:"object"} to narrow), ' +
624
+ 'aggregateRetained(ids[])->{retained,exact} (dominator-deduped retained for a SET of ids, no double-counting), ' +
625
+ 'iterByClass(name, {type?, instancesOnly?})->nodes[] & iterByType(type)->nodes[] (INDEXED iteration — no full scan; instancesOnly defaults TRUE and drops the constructor closure, the `Foo (prototype)` object and `system/SharedFunctionInfo/Foo`, which otherwise come back as class members whose only "properties" are length/map and make a per-instance loop produce garbage), ' +
626
+ 'classCounts({pattern?, type?, minCount?})->[{name,type,count,selfSize}] (one-pass histogram, cached), ' +
627
+ 'entries(nodeOrId)->[{key,value}] (generic Map/Set/WeakMap/Array/object walk, holes filtered), ' +
628
+ 'edgeTarget(nodeOrId, edgeName)->node|null, walkChain(startOrNode, edgeName, {maxHops?, collectIds?})->{length, terminated:"cycle"|"end"|"cap", truncated} (USE THIS instead of a hand-written `while` over `.next` — a hand-rolled loop cannot tell a circular list from its own hop cap, and reports the cap as if it were the length), isRealDetached(node)->boolean (same filtering the tools apply internally), ' +
629
+ 'dominates(id, {population?, limit?})->{count,selfSize,ids,truncated}, ' +
630
+ 'remember(name, value)/recall(name?) (persist ACROSS sessions), ' +
631
+ 'sample(items, n) (deterministic, evenly spaced), ' +
632
+ 'owner(idOrNode, {maxHops?})->{id,name,type,hops,selfSize,named}|null, ' +
633
+ 'histogram(ids, keyFn, {limit?})->[{key,count}], ' +
634
+ 'pathBetween(fromId, toId, {maxNodes?})->{found,exhausted,path[]}, ' +
635
+ 'save(name, value) / load(name, {allowCrossSnapshot?}) / listSaved() (SESSION-scoped, survives loading another snapshot) }), ' +
147
636
  'and standard JS built-ins. ' +
637
+ 'NOTE: `helpers.byClass()` returns IDS, and not every id resolves — `snapshot.getNodeById()` returns null for many native classes (AudioContext, OpusRecorder, …), so `byClass(x).map(id => getNodeById(id).referrers)` throws on the first try. Use `helpers.nodesByClass(name)` / `helpers.iterByClass(name)`, which return node objects and skip the unresolvable ones. `helpers.props()` on an unresolvable node now returns `{__unavailable: true}` rather than `{}`, so "no properties" and "could not read properties" are distinguishable. ' +
638
+ 'NOTE: `node.retainedSize` / `node.retained_size` THROW inside eval — they can read back ~0 for every node on some loads, so a silent wrong number is refused; use helpers.retainedSize(id). `node.self_size` is reliable. ' +
148
639
  'Node traversal: use node.references (outgoing) and node.referrers (incoming) with for-of. ' +
149
640
  'Edge properties: .name_or_index, .type, .toNode, .fromNode.'),
150
641
  timeout_ms: z
151
642
  .number()
152
643
  .optional()
153
- .default(60000)
154
- .describe('Execution timeout in milliseconds (default 60000). Full-snapshot scans on large heaps may need 120000+.'),
155
- }, async ({ mode, code, timeout_ms }) => {
156
- try {
157
- if (mode === 'describe_env') {
158
- return toolResult(describeEnv());
159
- }
160
- if (code == null || code.trim() === '') {
161
- return errorResult(new Error('No code provided. Pass `code`, or use mode:"describe_env" to see the available globals and conventions.'));
162
- }
163
- const snapshot = getSnapshot();
164
- const consoleOutput = [];
165
- const capturedConsole = {
166
- log: (...args) => consoleOutput.push(args.map(String).join(' ')),
167
- warn: (...args) => consoleOutput.push('[warn] ' + args.map(String).join(' ')),
168
- error: (...args) => consoleOutput.push('[error] ' + args.map(String).join(' ')),
169
- info: (...args) => consoleOutput.push('[info] ' + args.map(String).join(' ')),
170
- };
171
- const groupReferrersByEdge = (nodeId) => {
172
- const target = snapshot.getNodeById(nodeId);
173
- if (!target)
174
- return {};
175
- const groups = {};
176
- for (const edge of target.referrers) {
177
- const eName = String(edge.name_or_index);
178
- const from = edge.fromNode;
179
- if (!groups[eName])
180
- groups[eName] = [];
181
- if (groups[eName].length < 10) {
182
- groups[eName].push({
183
- fromName: from.name,
184
- fromType: from.type,
185
- fromId: from.id,
186
- });
644
+ .describe('Execution timeout in milliseconds. Defaults to a value SCALED from the loaded snapshot (60s floor, ~1s per 40k nodes), because a full-heap walk on a 6M-node graph takes minutes and a fixed 60s default made the first attempt abort on every large capture.'),
645
+ max_result_bytes: z
646
+ .number()
647
+ .int()
648
+ .min(1024)
649
+ .optional()
650
+ .describe(`Byte budget for the serialized \`result\` (default ${MAX_OUTPUT_SIZE}). Over budget, whole ENTRIES are dropped from the end of an array/object rather than the JSON being cut mid-string, and \`truncated: true\` is reported — so a large census stays valid and readable instead of needing a re-run with a hand-written limit.`),
651
+ save_as: z
652
+ .string()
653
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
654
+ .optional()
655
+ .describe('Save this call\'s `result` under a name, reusable in later calls via `helpers.load("<name>")`. Lets a multi-step investigation keep intermediate sets (candidate ids, per-id measurements) SERVER-SIDE instead of round-tripping them through the transcript. Save plain data (ids, counts, strings) — not node objects. Scoped to the current snapshot and dropped when it is unloaded.'),
656
+ dry_run: z
657
+ .boolean()
658
+ .optional()
659
+ .default(false)
660
+ .describe('Report what the code WOULD scan and stop, without running it (default false). Returns the snapshot size, whether the code contains a full-heap walk, and the effective max_nodes budget. Use it before an exploratory scan on a multi-million-node heap, where the difference between an indexed lookup and a full walk is the difference between milliseconds and minutes.'),
661
+ sample: z
662
+ .number()
663
+ .int()
664
+ .min(1)
665
+ .optional()
666
+ .default(1)
667
+ .describe('TRIAGE MODE: visit only every Nth node in a `snapshot.nodes.forEach` walk (1 = every node, the default). A full-heap walk on a multi-million-node graph takes 1-2 minutes, which is enough friction that most exploratory ideas never get run at all; `sample: 200` answers "is there anything here?" in about a second, and you pay for the exact walk only once an idea looks worth it. The stride is deterministic, not random, so a follow-up question lands on the same objects. COUNTS COME BACK ~N TIMES LOW and the result is labelled an ESTIMATE — never record a sampled number as a measurement, and never conclude ABSENCE from one (a population of 50 is easily missed at stride 200).'),
668
+ section: z
669
+ .string()
670
+ .optional()
671
+ .describe('For mode:"describe_env" only — return just the section(s) whose heading matches this text (case-insensitive substring), e.g. "collection", "populations", "traversal", "IHeapNode". The calling conventions and the section list are always included. The full document is ~10 KB of tokens and is usually read to write one eval.'),
672
+ restrict_to_ids: z
673
+ .array(z.number())
674
+ .optional()
675
+ .describe('Restrict `snapshot.nodes.forEach` to these node ids, so the SAME code runs over a candidate set instead of the whole graph. This is how a `sample`-based triage pass is promoted to an exact answer without paying for a second blind full walk: run `sample: 200` with `save_as: "candidates"` collecting ids, then re-run with `restrict_to_ids: helpers.load("candidates")` and `sample: 1`. Filtering happens inside the walk, so `nodes_visited` reports the real work.'),
676
+ max_nodes: z
677
+ .number()
678
+ .int()
679
+ .min(1)
680
+ .optional()
681
+ .describe('Abort a `snapshot.nodes.forEach` walk after this many node visits. Defaults to a budget SCALED from the loaded graph (6 full passes, floored at 20,000,000) — a probe that makes several passes is the normal shape, so a flat cap silently truncated ordinary multi-pass analysis on large heaps. On abort the partial `result` is returned with a note; an abort that produced NO result is refused outright, because an empty value is indistinguishable from a genuine empty census. Reported back as `nodes_visited` on every call.'),
682
+ }, async (args) => runEval({ ...args, ownsScanBudget: true }));
683
+ }
684
+ /**
685
+ * The `memlab_eval` handler, callable directly so a caller can run the same
686
+ * code against several snapshots (see memlab_eval_across) with identical
687
+ * sandbox semantics — one definition of the helper surface, not two.
688
+ */
689
+ /**
690
+ * Cross-session scratch for `helpers.remember` / `helpers.recall`. Kept beside
691
+ * the metric store (same MEMLAB_STATE_DIR) because it answers the same problem
692
+ * from the other end: `memlab_metric` persists a NUMBER worth quoting, this
693
+ * persists whatever an exploration derived on the way to it.
694
+ */
695
+ function evalStorePath() {
696
+ const dir = process.env.MEMLAB_STATE_DIR ?? path.join(os.homedir(), '.memlab');
697
+ return path.join(dir, 'eval-store.json');
698
+ }
699
+ function readEvalStore() {
700
+ try {
701
+ const parsed = JSON.parse(fs.readFileSync(evalStorePath(), 'utf8'));
702
+ if (parsed != null && typeof parsed === 'object') {
703
+ return parsed;
704
+ }
705
+ }
706
+ catch {
707
+ // A missing or corrupt store must not fail the eval that is writing to it.
708
+ }
709
+ return {};
710
+ }
711
+ function writeEvalStore(store) {
712
+ const file = evalStorePath();
713
+ fs.mkdirSync(path.dirname(file), { recursive: true });
714
+ fs.writeFileSync(file, JSON.stringify(store, null, 2));
715
+ }
716
+ export async function runEval({ mode, code, section, timeout_ms, save_as, max_nodes, sample, restrict_to_ids, dry_run, max_result_bytes, ownsScanBudget, }) {
717
+ // Schema defaults are applied by the MCP layer for tool calls; a direct
718
+ // caller (memlab_eval_across) gets them here so both paths behave alike.
719
+ //
720
+ // Anything that needs to scale with the LOADED SNAPSHOT therefore must NOT
721
+ // carry a `.default()` in the zod schema — the MCP layer fills that in before
722
+ // this function runs, so the `?? scaled...()` below never fires and the
723
+ // scaling is silently dead for every tool call. `max_nodes` shipped with
724
+ // exactly that bug: the schema default of 20,000,000 shadowed the scaled
725
+ // budget, and a three-pass probe on an 8.06M-node capture still aborted at
726
+ // 20,000,000 while the error text correctly reported the graph as 8,055,593
727
+ // nodes. Both schemas now leave it optional-with-no-default.
728
+ mode = mode ?? 'eval';
729
+ const scaledTimeout = timeout_ms ?? scaledEvalTimeoutMs();
730
+ if (ownsScanBudget === true && timeout_ms == null && scaledTimeout > 0) {
731
+ // Raising only the VM script timeout is not enough, and the half-fix is
732
+ // worse than none because it looks like it worked. `guardrail.ts` arms the
733
+ // whole-heap scan budget from the tool's INCOMING `timeout_ms` before this
734
+ // handler runs; with no explicit value it arms the 90s default, so a scaled
735
+ // 150s eval on a 6M-node graph is still killed at 90s — the exact failure
736
+ // the scaling exists to remove. Re-arm the scan budget to match.
737
+ //
738
+ // Gated on `ownsScanBudget` because `beginAnalysisBudget` RESETS the clock
739
+ // rather than extending it, and the registry's invariant is exactly one
740
+ // budget per MCP call. `memlab_eval_across` runs one eval per rung inside a
741
+ // single call, so an ungated re-arm would hand every rung a fresh budget
742
+ // and leave the batch's total wall clock effectively unbounded — the guard
743
+ // the budget exists to be.
744
+ beginAnalysisBudget(scaledTimeout);
745
+ }
746
+ timeout_ms = scaledTimeout;
747
+ // Scale the walk budget to the graph, the way the timeout already scales.
748
+ // A flat 20M was under one snapshot's worth of visits for any probe that
749
+ // makes more than two passes over a large heap: on an 8.06M-node capture a
750
+ // routine three-pass probe hit the cap and returned a PARTIAL result, which —
751
+ // because the accumulator was assigned at the end — printed as a bare
752
+ // `undefined`. A budget that silently converts "too big" into "no answer"
753
+ // is worse than one that is simply large.
754
+ max_nodes = max_nodes ?? scaledWalkBudget();
755
+ dry_run = dry_run ?? false;
756
+ const budget = {
757
+ visited: 0,
758
+ max: max_nodes,
759
+ exceeded: false,
760
+ sampleEvery: sample != null && sample > 1 ? Math.floor(sample) : 1,
761
+ seen: 0,
762
+ restrictTo: restrict_to_ids != null && restrict_to_ids.length > 0
763
+ ? new Set(restrict_to_ids)
764
+ : undefined,
765
+ };
766
+ try {
767
+ if (mode === 'lint') {
768
+ return toolResult(lintEval(code ?? '', lastKnownHelperNames));
769
+ }
770
+ if (mode === 'describe_env') {
771
+ return toolResult(describeEnv(section));
772
+ }
773
+ if (mode === 'list_saved') {
774
+ return toolResult(describeSaved());
775
+ }
776
+ if (code == null || code.trim() === '') {
777
+ return errorResult(new Error('No code provided. Pass `code`, or use mode:"describe_env" to see the available globals and conventions.'));
778
+ }
779
+ // Light snapshots are allowed here. Most eval code touches only
780
+ // `name`, `type`, `self_size`, `references` and `referrers`, none of
781
+ // which the dominator pass produces — refusing the whole tool forced a
782
+ // full (2x slower) load for counts-only work on a baseline rung. What
783
+ // IS unavailable is refused precisely instead: by a pre-flight text
784
+ // check below, and by the helpers themselves as a backstop.
785
+ const light = isLightSnapshot();
786
+ const snapshot = getSnapshot({ allowLight: true });
787
+ const currentHandle = getCurrentHandle() ?? '(none)';
788
+ if (light) {
789
+ const needsRetention = RETENTION_IDENTIFIERS.filter(id => new RegExp(`\\b${id}\\b`).test(code));
790
+ if (needsRetention.length > 0) {
791
+ return errorResult(new Error(`This snapshot was loaded in LIGHT mode (no dominator tree, no retained sizes, no shortest-path edges), and the code references ${needsRetention.map(i => `\`${i}\``).join(', ')}. ` +
792
+ 'Those would read 0 / undefined rather than fail, so the run is refused instead of returning confident zeros. ' +
793
+ 'Reload without `light` for retention work, or drop the reference — counts, names, types, self sizes, string values and edge walks all work fine on a light snapshot. ' +
794
+ '(If the identifier only appears inside a string literal, this is a false match; the same call succeeds on a non-light load.)'));
795
+ }
796
+ }
797
+ if (dry_run) {
798
+ // Estimate, do not execute. A full-heap walk is detected textually —
799
+ // the honest limit of a pre-flight check, and stated as such rather
800
+ // than implying the code was analysed.
801
+ const meta = getSnapshotMetadata();
802
+ const fullWalk = /\b(?:snapshot\.)?(?:nodes|edges)\s*\.\s*forEach/.test(code);
803
+ const indexed = /helpers\.(byClass|byTypename|withProp|byReferrerEdge|getNode)/.test(code);
804
+ const nestingDepth = maxTraversalNesting(code);
805
+ const meanOutDegree = (meta?.edgeCount ?? 0) / Math.max(1, meta?.nodeCount ?? 1);
806
+ // A nested `.references` walk inside a full-heap walk costs EDGE visits,
807
+ // not node visits, and the difference is the difference between seconds
808
+ // and "the probe died at 600s having produced nothing". `max_nodes`
809
+ // bounds the outer loop only, so nothing else warns about this.
810
+ const estimatedVisits = fullWalk && nestingDepth > 0
811
+ ? (meta?.nodeCount ?? 0) * Math.pow(meanOutDegree, nestingDepth)
812
+ : null;
813
+ return toolResult([
814
+ '## Dry run — nothing was executed',
815
+ '',
816
+ `Snapshot: ${formatNumber(meta?.nodeCount ?? 0)} nodes, ${formatNumber(meta?.edgeCount ?? 0)} edges.`,
817
+ `Walk budget (\`max_nodes\`): ${formatNumber(budget.max)}.`,
818
+ '',
819
+ fullWalk
820
+ ? `⚠ The code contains a full-heap walk, so it will visit up to ${formatNumber(Math.min(budget.max, meta?.nodeCount ?? 0))} nodes. On a heap this size that is seconds to minutes.${indexed ? '' : ' `helpers.byClass` / `byTypename` / `withProp` / `byReferrerEdge` are indexed and avoid the walk when you know what you are looking for.'}`
821
+ : indexed
822
+ ? 'No full-heap walk detected; the code uses the indexed helpers, which do not scan the heap.'
823
+ : 'No full-heap walk detected by text match. This is a textual check, not an analysis — a walk reached indirectly will not be seen here.',
824
+ estimatedVisits != null && nestingDepth >= 1
825
+ ? `⚠ NESTED TRAVERSAL, depth ${nestingDepth}: the walk iterates \`.references\`/\`.referrers\` inside the outer loop, so the real cost is EDGE visits — roughly ${formatNumber(Math.round(estimatedVisits))} (${formatNumber(meta?.nodeCount ?? 0)} nodes x mean out-degree ${meanOutDegree.toFixed(1)}^${nestingDepth}). \`max_nodes\` bounds the OUTER loop only and will not stop this.${nestingDepth >= 2 ? ' At depth 2 or more, expect minutes: hoist the inner lookup, or collect candidate ids in a cheap pass and re-run with `restrict_to_ids`.' : ''}`
826
+ : '',
827
+ '',
828
+ '_Re-run without `dry_run` to execute._',
829
+ ].join('\n'));
830
+ }
831
+ const consoleOutput = [];
832
+ const capturedConsole = {
833
+ log: (...args) => consoleOutput.push(args.map(String).join(' ')),
834
+ warn: (...args) => consoleOutput.push('[warn] ' + args.map(String).join(' ')),
835
+ error: (...args) => consoleOutput.push('[error] ' + args.map(String).join(' ')),
836
+ info: (...args) => consoleOutput.push('[info] ' + args.map(String).join(' ')),
837
+ };
838
+ const groupReferrersByEdge = (nodeId) => {
839
+ const target = snapshot.getNodeById(nodeId);
840
+ if (!target)
841
+ return {};
842
+ const groups = {};
843
+ for (const edge of target.referrers) {
844
+ const eName = String(edge.name_or_index);
845
+ const from = edge.fromNode;
846
+ if (!groups[eName])
847
+ groups[eName] = [];
848
+ if (groups[eName].length < 10) {
849
+ groups[eName].push({
850
+ fromName: from.name,
851
+ fromType: from.type,
852
+ fromId: from.id,
853
+ });
854
+ }
855
+ }
856
+ return groups;
857
+ };
858
+ const groupArrayElementsByProperty = (arrayNodeId, propertyName) => {
859
+ const arrNode = snapshot.getNodeById(arrayNodeId);
860
+ if (!arrNode)
861
+ return { error: 'Node not found' };
862
+ const groups = {};
863
+ let missing = 0;
864
+ let total = 0;
865
+ for (const edge of arrNode.references) {
866
+ if (edge.type !== 'element')
867
+ continue;
868
+ const elem = edge.toNode;
869
+ if (elem.id <= 3)
870
+ continue;
871
+ total++;
872
+ let found = false;
873
+ for (const propEdge of elem.references) {
874
+ if (String(propEdge.name_or_index) === propertyName) {
875
+ const target = propEdge.toNode;
876
+ const key = target.name;
877
+ if (!groups[key])
878
+ groups[key] = { count: 0, exampleId: target.id };
879
+ groups[key].count++;
880
+ found = true;
881
+ break;
187
882
  }
188
883
  }
189
- return groups;
190
- };
191
- const groupArrayElementsByProperty = (arrayNodeId, propertyName) => {
192
- const arrNode = snapshot.getNodeById(arrayNodeId);
193
- if (!arrNode)
194
- return { error: 'Node not found' };
195
- const groups = {};
196
- let missing = 0;
197
- let total = 0;
198
- for (const edge of arrNode.references) {
199
- if (edge.type !== 'element')
200
- continue;
201
- const elem = edge.toNode;
202
- if (elem.id <= 3)
203
- continue;
204
- total++;
205
- let found = false;
206
- for (const propEdge of elem.references) {
207
- if (String(propEdge.name_or_index) === propertyName) {
208
- const target = propEdge.toNode;
209
- const key = target.name;
210
- if (!groups[key])
211
- groups[key] = { count: 0, exampleId: target.id };
212
- groups[key].count++;
213
- found = true;
214
- break;
215
- }
884
+ if (!found)
885
+ missing++;
886
+ }
887
+ return { groups, total, missing };
888
+ };
889
+ const isOrphaned = (nodeId, ownershipEdgeNames) => {
890
+ const target = snapshot.getNodeById(nodeId);
891
+ if (!target)
892
+ return false;
893
+ const ownerSet = new Set(ownershipEdgeNames);
894
+ for (const edge of target.referrers) {
895
+ if (ownerSet.has(String(edge.name_or_index)))
896
+ return false;
897
+ }
898
+ return true;
899
+ };
900
+ // Authoritative retained sizes (Feedback round 3 §3b). Reading
901
+ // `.retainedSize` off proxied/iterated nodes inside eval can come back
902
+ // ~0; these helpers look the node up fresh on the real snapshot (the
903
+ // same path the dedicated tools use) so custom analyses can rank by
904
+ // retained size.
905
+ // On a light snapshot these would return 0 for every id, which is
906
+ // indistinguishable from a genuinely tiny object. Throw instead: the
907
+ // pre-flight check above catches the common case, and this covers code
908
+ // that reaches them indirectly.
909
+ const requireRetention = (what) => {
910
+ if (!light)
911
+ return;
912
+ throw new Error(`helpers.${what} needs retained sizes, which a LIGHT snapshot does not have (it would return 0 for every id). Reload with memlab_load_snapshot({file_path, light: false}).`);
913
+ };
914
+ const retainedSize = (id) => {
915
+ requireRetention('retainedSize');
916
+ const n = snapshot.getNodeById(id);
917
+ return n ? n.retainedSize : 0;
918
+ };
919
+ const retainedSizes = (ids) => {
920
+ requireRetention('retainedSizes');
921
+ const out = {};
922
+ for (const id of ids) {
923
+ const n = snapshot.getNodeById(id);
924
+ out[id] = n ? n.retainedSize : 0;
925
+ }
926
+ return out;
927
+ };
928
+ const countUniqueTargets = (arrayNodeId, propertyName) => {
929
+ const arrNode = snapshot.getNodeById(arrayNodeId);
930
+ if (!arrNode)
931
+ return { error: 'Node not found' };
932
+ const uniqueIds = new Set();
933
+ let total = 0;
934
+ for (const edge of arrNode.references) {
935
+ if (edge.type !== 'element')
936
+ continue;
937
+ const elem = edge.toNode;
938
+ if (elem.id <= 3)
939
+ continue;
940
+ total++;
941
+ for (const propEdge of elem.references) {
942
+ if (String(propEdge.name_or_index) === propertyName) {
943
+ uniqueIds.add(propEdge.toNode.id);
944
+ break;
216
945
  }
217
- if (!found)
218
- missing++;
219
946
  }
220
- return { groups, total, missing };
947
+ }
948
+ return { uniqueCount: uniqueIds.size, totalElements: total };
949
+ };
950
+ // Compact, ready-to-use view of a node (no proxy, values inlined) so
951
+ // custom scripts get data they can JSON-return directly instead of
952
+ // re-deriving `.toStringNode()?.stringValue` etc. `retained_size` is
953
+ // resolved via the trusted `retainedSize(id)` re-lookup (NOT the raw
954
+ // `.retainedSize`, which can read back ~0 inside eval on some loads — the
955
+ // foot-gun this whole tool's description warns about).
956
+ //
957
+ // On a LIGHT snapshot it is reported as `null` rather than thrown:
958
+ // `mapEntries` / `setElements` are built on this and are legitimate
959
+ // there (keys, values, names and self sizes all survive a light load),
960
+ // so propagating `retainedSize`'s refusal would refuse them too. A null
961
+ // reads as "not measured"; a 0 would read as "measured and tiny".
962
+ const nodeBrief = (n) => n == null
963
+ ? null
964
+ : {
965
+ id: n.id,
966
+ name: n.name,
967
+ type: n.type,
968
+ self_size: n.self_size,
969
+ retained_size: light ? null : retainedSize(n.id),
970
+ string: n.isString ? (n.toStringNode()?.stringValue ?? null) : null,
221
971
  };
222
- const isOrphaned = (nodeId, ownershipEdgeNames) => {
223
- const target = snapshot.getNodeById(nodeId);
224
- if (!target)
972
+ const resolveNode = (nodeOrId) => {
973
+ if (nodeOrId == null)
974
+ return null;
975
+ const id = typeof nodeOrId === 'number' ? nodeOrId : nodeOrId.id;
976
+ return snapshot.getNodeById(id);
977
+ };
978
+ // Correctly enumerate Map/WeakMap entries and Set elements via the shared
979
+ // index-aware backing-store walk (handles browser `internal`-typed slots
980
+ // AND SMI-value gaps). Removes the #1 eval foot-gun: hand-rolling this
981
+ // and silently getting 0 results by filtering on `type === 'element'`.
982
+ const mapEntries = (id, limit = 1000) => {
983
+ const node = snapshot.getNodeById(id);
984
+ if (!node)
985
+ throw new Error(`mapEntries: node @${id} not found`);
986
+ // Guard the node type — enumerateMapEntries assumes key/value slots, so
987
+ // running it on a Set (element/chain layout) would emit each element as
988
+ // a lone key with value:null, which is silently misleading.
989
+ if (node.name !== 'Map' && node.name !== 'WeakMap') {
990
+ throw new Error(`mapEntries: @${id} is a ${node.name} (${node.type}), not a Map/WeakMap. ` +
991
+ `For a Set use helpers.setElements(${id}); otherwise inspect with helpers.props()/get_references.`);
992
+ }
993
+ return enumerateMapEntries(node)
994
+ .slice(0, limit)
995
+ .map(e => ({ key: nodeBrief(e.key), value: nodeBrief(e.value) }));
996
+ };
997
+ const setElements = (id, limit = 1000) => {
998
+ const node = snapshot.getNodeById(id);
999
+ if (!node)
1000
+ throw new Error(`setElements: node @${id} not found`);
1001
+ if (node.name !== 'Set' && node.name !== 'WeakSet') {
1002
+ throw new Error(`setElements: @${id} is a ${node.name} (${node.type}), not a Set/WeakSet. ` +
1003
+ `For a Map use helpers.mapEntries(${id}).`);
1004
+ }
1005
+ return enumerateSetElements(node).slice(0, limit).map(nodeBrief);
1006
+ };
1007
+ // Read an object's own properties as a plain object: scalars inlined,
1008
+ // object-valued props as `{ref, name, type}`. Saves the repetitive
1009
+ // `for (const e of n.references) if (e.name_or_index === X)` boilerplate.
1010
+ const describeTarget = (t) => {
1011
+ if (t.isString)
1012
+ return t.toStringNode()?.stringValue ?? '';
1013
+ if (t.name === 'true')
1014
+ return true;
1015
+ if (t.name === 'false')
1016
+ return false;
1017
+ if (t.name === 'null')
1018
+ return null;
1019
+ if (t.name === 'undefined')
1020
+ return undefined;
1021
+ return { ref: t.id, name: t.name, type: t.type };
1022
+ };
1023
+ const props = (nodeOrId) => {
1024
+ const node = resolveNode(nodeOrId);
1025
+ // An empty object used to mean three different things — unresolvable
1026
+ // node, no property edges, and properties held under a non-`property`
1027
+ // edge type — and the caller could not tell which. Reading `{}` as "this
1028
+ // object has no fields" when it plainly does is what makes shape
1029
+ // inspection unreliable and sends people back to manual edge walks.
1030
+ if (!node) {
1031
+ return {
1032
+ __unavailable: true,
1033
+ __reason: 'node not resolvable in the active snapshot — ids from ' +
1034
+ 'helpers.byClass() are not all resolvable (natives especially); ' +
1035
+ 'use helpers.iterByClass() to get node objects directly',
1036
+ };
1037
+ }
1038
+ const out = {};
1039
+ for (const e of node.references) {
1040
+ if (e.type !== 'property')
1041
+ continue;
1042
+ const name = String(e.name_or_index);
1043
+ if (name === '__proto__')
1044
+ continue;
1045
+ out[name] = describeTarget(e.toNode);
1046
+ }
1047
+ if (Object.keys(out).length > 0)
1048
+ return out;
1049
+ // Fall back to a named-edge walk. Natives, closures and some internal
1050
+ // objects carry their fields under `internal`/`shortcut`/`hidden` edges
1051
+ // rather than `property`, so the fast path legitimately finds nothing on
1052
+ // objects that visibly have state. Provenance is marked so a caller
1053
+ // cannot mistake these for real own-properties.
1054
+ let found = 0;
1055
+ for (const e of node.references) {
1056
+ if (e.type === 'element')
1057
+ continue;
1058
+ const name = String(e.name_or_index);
1059
+ if (name === '' || name === '__proto__' || /^\d+$/.test(name))
1060
+ continue;
1061
+ out[name] = describeTarget(e.toNode);
1062
+ found++;
1063
+ }
1064
+ if (found === 0)
1065
+ return out;
1066
+ out.__via = 'edge-walk';
1067
+ out.__note =
1068
+ 'no `property` edges on this node; these are named non-property edges ' +
1069
+ '(internal/shortcut/hidden) and are NOT own JS properties';
1070
+ return out;
1071
+ };
1072
+ const getProp = (nodeOrId, name) => props(nodeOrId)[name];
1073
+ /**
1074
+ * Own JS properties ONLY — no `internal`/`shortcut`/`hidden` edges, no
1075
+ * `__via`/`__note` provenance keys, no fallback.
1076
+ *
1077
+ * `props()` deliberately falls back to a named-edge walk so that natives and
1078
+ * closures show their state, and marks the result. That is right for
1079
+ * INSPECTION and wrong for SHAPE MATCHING, and the failure is silent in the
1080
+ * worst direction: a shape test written as
1081
+ * `new Set(Object.keys(helpers.props(id)))` … `s.size === 2 && s.has('element')`
1082
+ * returns ZERO matches on objects that plainly have that shape, because the
1083
+ * fallback injected `length`/`map`/`__via`/`__note`. An empty result reads as
1084
+ * "this pattern is not in the heap" and gets written up as a negative.
1085
+ *
1086
+ * Use `ownProps`/`shapeKeys` whenever the question is "what shape is this",
1087
+ * and `props` when the question is "what is in this".
1088
+ */
1089
+ const ownProps = (nodeOrId) => {
1090
+ const node = resolveNode(nodeOrId);
1091
+ if (!node)
1092
+ return {};
1093
+ const out = {};
1094
+ for (const e of node.references) {
1095
+ if (e.type !== 'property')
1096
+ continue;
1097
+ const name = String(e.name_or_index);
1098
+ if (name === '__proto__')
1099
+ continue;
1100
+ out[name] = describeTarget(e.toNode);
1101
+ }
1102
+ return out;
1103
+ };
1104
+ const shapeKeys = (nodeOrId) => {
1105
+ const node = resolveNode(nodeOrId);
1106
+ const out = new Set();
1107
+ if (!node)
1108
+ return out;
1109
+ for (const e of node.references) {
1110
+ if (e.type !== 'property')
1111
+ continue;
1112
+ const name = String(e.name_or_index);
1113
+ if (name === '__proto__')
1114
+ continue;
1115
+ out.add(name);
1116
+ }
1117
+ return out;
1118
+ };
1119
+ const hasShape = (nodeOrId, required, opts) => {
1120
+ const keys = shapeKeys(nodeOrId);
1121
+ for (const r of required)
1122
+ if (!keys.has(r))
1123
+ return false;
1124
+ for (const x of opts?.exclude ?? [])
1125
+ if (keys.has(x))
225
1126
  return false;
226
- const ownerSet = new Set(ownershipEdgeNames);
227
- for (const edge of target.referrers) {
228
- if (ownerSet.has(String(edge.name_or_index)))
229
- return false;
1127
+ if (opts?.exact === true && keys.size !== required.length)
1128
+ return false;
1129
+ return true;
1130
+ };
1131
+ const shapeSignature = (nodeOrId, opts) => {
1132
+ const node = resolveNode(nodeOrId);
1133
+ return node ? objectContentSignature(node, opts ?? {}) : '';
1134
+ };
1135
+ // Index helpers — build once per snapshot, memoized in the eval scratch
1136
+ // so a follow-up call is index-speed, not a fresh 12M-node scan. Ids are
1137
+ // only valid for the active snapshot (the scratch is keyed to it).
1138
+ const scratch = getEvalScratch();
1139
+ const buildClassTypeIndex = () => {
1140
+ const cached = scratch.__classTypeIndex;
1141
+ if (cached)
1142
+ return cached;
1143
+ const byClass = new Map();
1144
+ const byTypename = new Map();
1145
+ snapshot.nodes.forEach((node) => {
1146
+ if (node.id <= 3)
1147
+ return; // skip oddball/root nodes, matching the histogram/duplicate-objects tools for count parity
1148
+ // Index EVERY node type. Restricting this to `object` made the
1149
+ // helper silently return [] for closures, strings, arrays and
1150
+ // native (`blink::*`) nodes — which is most of what other tools
1151
+ // report. Measured: byClass('setComposerLinks_$0') returned [] on a
1152
+ // snapshot where a manual walk found 1,011 of them, because the
1153
+ // class is a closure; the empty result reads as "does not exist".
1154
+ // memlab_find_nodes_by_class matches any type by default and this
1155
+ // helper is documented as its indexed equivalent, so the two must
1156
+ // agree.
1157
+ let a = byClass.get(node.name);
1158
+ if (!a) {
1159
+ a = [];
1160
+ byClass.set(node.name, a);
1161
+ }
1162
+ a.push(node.id);
1163
+ // `__typename` is a JS object property, so only object nodes can
1164
+ // carry one; skipping the edge walk for other types keeps the
1165
+ // widened index roughly as cheap as the object-only one.
1166
+ if (node.type !== 'object')
1167
+ return;
1168
+ for (const e of node.references) {
1169
+ if (e.type === 'property' &&
1170
+ String(e.name_or_index) === '__typename') {
1171
+ const t = e.toNode;
1172
+ const tn = t.isString ? t.toStringNode()?.stringValue : null;
1173
+ if (tn) {
1174
+ let b = byTypename.get(tn);
1175
+ if (!b) {
1176
+ b = [];
1177
+ byTypename.set(tn, b);
1178
+ }
1179
+ b.push(node.id);
1180
+ }
1181
+ break;
1182
+ }
230
1183
  }
1184
+ });
1185
+ const idx = { byClass, byTypename };
1186
+ scratch.__classTypeIndex = idx;
1187
+ return idx;
1188
+ };
1189
+ /**
1190
+ * A class index entry is not necessarily an INSTANCE of that class. V8 names
1191
+ * the constructor closure, the prototype object and the `SharedFunctionInfo`
1192
+ * after the class too, so `iterByClass('Resolvable')` hands back nodes whose
1193
+ * only "properties" are `length`/`map` — and a per-instance loop over them
1194
+ * produces confident garbage. (Measured: `Resolvable` and `JobInfoEvent`
1195
+ * both came back looking like empty objects for exactly this reason.)
1196
+ *
1197
+ * `instancesOnly` (default true) drops those three shapes. Pass false to get
1198
+ * the raw index back.
1199
+ */
1200
+ const isClassScaffolding = (id, className) => {
1201
+ const n = snapshot.getNodeById(id);
1202
+ if (!n)
1203
+ return false;
1204
+ // `Foo (prototype) / https://…` and `system / SharedFunctionInfo / Foo`.
1205
+ if (n.name !== className)
231
1206
  return true;
232
- };
233
- // Authoritative retained sizes (Feedback round 3 §3b). Reading
234
- // `.retainedSize` off proxied/iterated nodes inside eval can come back
235
- // ~0; these helpers look the node up fresh on the real snapshot (the
236
- // same path the dedicated tools use) so custom analyses can rank by
237
- // retained size.
238
- const retainedSize = (id) => {
239
- const n = snapshot.getNodeById(id);
240
- return n ? n.retainedSize : 0;
241
- };
242
- const retainedSizes = (ids) => {
243
- const out = {};
244
- for (const id of ids) {
245
- const n = snapshot.getNodeById(id);
246
- out[id] = n ? n.retainedSize : 0;
1207
+ if (n.type === 'code' || n.type === 'synthetic')
1208
+ return true;
1209
+ // The constructor closure itself: a closure named exactly like the class
1210
+ // whose only outgoing named edge is `prototype`.
1211
+ if (n.type === 'closure') {
1212
+ for (const e of n.references) {
1213
+ if (e.type === 'property' &&
1214
+ String(e.name_or_index) === 'prototype') {
1215
+ return true;
1216
+ }
247
1217
  }
248
- return out;
249
- };
250
- const countUniqueTargets = (arrayNodeId, propertyName) => {
251
- const arrNode = snapshot.getNodeById(arrayNodeId);
252
- if (!arrNode)
253
- return { error: 'Node not found' };
254
- const uniqueIds = new Set();
255
- let total = 0;
256
- for (const edge of arrNode.references) {
257
- if (edge.type !== 'element')
1218
+ }
1219
+ return false;
1220
+ };
1221
+ const byClass = (name, opts) => {
1222
+ const raw = buildClassTypeIndex().byClass.get(name) ?? [];
1223
+ const want = opts?.type;
1224
+ const ids = want == null
1225
+ ? raw
1226
+ : raw.filter(id => snapshot.getNodeById(id)?.type === want);
1227
+ if (opts?.instancesOnly === false)
1228
+ return ids;
1229
+ const filtered = ids.filter(id => !isClassScaffolding(id, name));
1230
+ // If the filter would empty a non-empty class, the heuristic is wrong for
1231
+ // this shape — hand back what we had rather than report "not present".
1232
+ return filtered.length > 0 || ids.length === 0 ? filtered : ids;
1233
+ };
1234
+ const byTypename = (name) => buildClassTypeIndex().byTypename.get(name) ?? [];
1235
+ const withProp = (name) => {
1236
+ const key = `__withProp:${name}`;
1237
+ const cached = scratch[key];
1238
+ if (cached)
1239
+ return cached;
1240
+ const ids = [];
1241
+ snapshot.nodes.forEach((node) => {
1242
+ if (node.id <= 3)
1243
+ return; // skip oddball/root nodes for parity with other tools
1244
+ // Every node type is scanned: the `property` edge check below is
1245
+ // what constrains the match, and closures do carry named property
1246
+ // edges. Restricting the walk to `object` hid them, the same way it
1247
+ // hid non-object classes from byClass.
1248
+ for (const e of node.references) {
1249
+ if (e.type === 'property' && String(e.name_or_index) === name) {
1250
+ ids.push(node.id);
1251
+ break;
1252
+ }
1253
+ }
1254
+ });
1255
+ scratch[key] = ids;
1256
+ return ids;
1257
+ };
1258
+ /**
1259
+ * The GC-root path for one node, as `retainer_trace` walks it — but callable
1260
+ * from inside an eval.
1261
+ *
1262
+ * This exact `pathEdge` loop was hand-written five separate times in one
1263
+ * session, once per probe that needed to name an owner, each time with
1264
+ * slightly different truncation. Shipping it makes the traversal consistent
1265
+ * with the dedicated tool and removes the boilerplate that discourages
1266
+ * asking "who holds this?" in the middle of a larger eval.
1267
+ *
1268
+ * Root first, target last. `maxHops` bounds pathological chains.
1269
+ */
1270
+ const rootPath = (nodeOrId, opts) => {
1271
+ const maxHops = opts?.maxHops ?? 24;
1272
+ // Collapsing is ON by default. Inside a linked-list leak — React update
1273
+ // queues, LRU chains, intrusive `.prev` lists — every hop is the SAME
1274
+ // edge, so the whole budget is spent walking the leak instead of escaping
1275
+ // it. Measured: 114 of 120 sampled update records returned
1276
+ // `Object.next -> Object.next -> ... (18 hops, no root)`, which
1277
+ // attributes nothing and then dominates the "top root paths" histogram
1278
+ // with an artifact of the walk.
1279
+ const collapse = opts?.collapseRepeatedEdges ?? true;
1280
+ let cur = resolveNode(nodeOrId);
1281
+ const out = [];
1282
+ const seen = new Set();
1283
+ let distinctHops = 0;
1284
+ let lastEdgeKey = null;
1285
+ let runLength = 0;
1286
+ while (cur != null && distinctHops < maxHops && !seen.has(cur.id)) {
1287
+ seen.add(cur.id);
1288
+ const edge = cur.hasPathEdge && cur.pathEdge ? cur.pathEdge : null;
1289
+ const edgeStr = edge
1290
+ ? `${String(edge.name_or_index)} [${edge.type}]`
1291
+ : null;
1292
+ // A run is the same edge name arriving at the same class — that is the
1293
+ // shape a chain makes, and it keeps two unrelated `.value` hops apart.
1294
+ const edgeKey = edgeStr == null ? null : `${cur.name}\u0000${edgeStr}`;
1295
+ if (collapse && edgeKey != null && edgeKey === lastEdgeKey) {
1296
+ runLength++;
1297
+ const prev = out[out.length - 1];
1298
+ prev.repeated = runLength + 1;
1299
+ }
1300
+ else {
1301
+ out.push({
1302
+ id: cur.id,
1303
+ name: cur.name,
1304
+ type: cur.type,
1305
+ edge: edgeStr,
1306
+ });
1307
+ lastEdgeKey = edgeKey;
1308
+ runLength = 0;
1309
+ // Only a DISTINCT hop spends budget; a 2,000-link chain should cost
1310
+ // one, so the remaining hops can reach the actual owner.
1311
+ distinctHops++;
1312
+ }
1313
+ if (!edge)
1314
+ break;
1315
+ cur = edge.fromNode;
1316
+ }
1317
+ return out.reverse();
1318
+ };
1319
+ // Dominator-deduped retained size for a SET of ids (bounded walk). Unlike
1320
+ // summing helpers.retainedSize over the ids, this does not double-count
1321
+ // bytes when one id dominates another in the set.
1322
+ const aggregateRetained = (ids) => {
1323
+ requireRetention('aggregateRetained');
1324
+ return boundedDominatorRetainedSize(new NumericSet(ids), snapshot);
1325
+ };
1326
+ // ---- additional traversal helpers -------------------------------
1327
+ // Each of these was hand-written inside `code` during a leak hunt,
1328
+ // several of them more than once and with small differences that made
1329
+ // results incomparable. Shipping them makes the common traversals both
1330
+ // cheaper to write and consistent with what the dedicated tools do.
1331
+ // The oddball/root filtering the tools apply internally. Hand-written
1332
+ // eval that omits it counts nodes the tools do not, so the two disagree
1333
+ // for reasons that have nothing to do with the question being asked.
1334
+ const isRealDetached = (node) => {
1335
+ const n = unwrapNode(node);
1336
+ if (n == null || n.id <= 3)
1337
+ return false;
1338
+ return n.is_detached || n.name.startsWith('Detached ');
1339
+ };
1340
+ /**
1341
+ * The scope object a closure captured, or null if it captured nothing.
1342
+ *
1343
+ * Exists because the hand-written version is wrong in a way that returns a
1344
+ * clean zero: the hop is an `internal` edge NAMED `context`, not a
1345
+ * `context`-TYPED edge (that type only appears on the edges INSIDE the
1346
+ * scope). Filtering on `e.type === 'context'` matches nothing on any heap.
1347
+ */
1348
+ const contextOf = (nodeOrId) => {
1349
+ const n = resolveNode(nodeOrId);
1350
+ if (n == null)
1351
+ return null;
1352
+ for (const e of n.references) {
1353
+ if (String(e.name_or_index) === 'context' && e.type === 'internal') {
1354
+ return wrapNode(e.toNode);
1355
+ }
1356
+ }
1357
+ return null;
1358
+ };
1359
+ /**
1360
+ * Every closure class, with how many of them captured a scope.
1361
+ *
1362
+ * A per-name count alone does not separate "1,000 copies of a function" from
1363
+ * "1,000 copies each pinning a distinct scope", and only the second is a
1364
+ * retention story. Cached, since it is a full pass.
1365
+ */
1366
+ const closureCensus = (opts) => {
1367
+ const key = '__closureCensus';
1368
+ let all = scratch[key];
1369
+ if (!all) {
1370
+ const acc = new Map();
1371
+ snapshot.nodes.forEach((node) => {
1372
+ if (node.id <= 3 || node.type !== 'closure')
1373
+ return;
1374
+ let rec = acc.get(node.name);
1375
+ if (!rec) {
1376
+ rec = { count: 0, withScope: 0 };
1377
+ acc.set(node.name, rec);
1378
+ }
1379
+ rec.count++;
1380
+ for (const e of node.references) {
1381
+ if (String(e.name_or_index) === 'context' &&
1382
+ e.type === 'internal') {
1383
+ rec.withScope++;
1384
+ break;
1385
+ }
1386
+ }
1387
+ });
1388
+ all = [...acc.entries()]
1389
+ .map(([name, r]) => ({ name, ...r }))
1390
+ .sort((a, b) => b.count - a.count);
1391
+ scratch[key] = all;
1392
+ }
1393
+ const minCount = opts?.minCount ?? 1;
1394
+ const re = opts?.pattern != null ? makeNamePatternTest(opts.pattern) : null;
1395
+ return all.filter(r => r.count >= minCount && (re == null || re(r.name)));
1396
+ };
1397
+ /**
1398
+ * Objects shaped like an event-listener record — carrying BOTH a
1399
+ * callback-ish and a context-ish property.
1400
+ *
1401
+ * This walk gets rewritten by hand almost every round, slightly differently
1402
+ * each time, which makes two rounds' numbers incomparable for reasons that
1403
+ * have nothing to do with the app. `callbackNamed` narrows to records whose
1404
+ * callback is a specific closure class, which is the form the question is
1405
+ * actually asked in ("how many `subscribe_$0` records are held?").
1406
+ */
1407
+ const listenerRecords = (callbackNamed) => {
1408
+ const key = '__listenerRecords';
1409
+ let all = scratch[key];
1410
+ if (!all) {
1411
+ const found = [];
1412
+ snapshot.nodes.forEach((node) => {
1413
+ if (node.id <= 3 || node.type !== 'object')
1414
+ return;
1415
+ let cb = null;
1416
+ let ctx = null;
1417
+ for (const e of node.references) {
1418
+ if (e.type !== 'property')
1419
+ continue;
1420
+ const p = String(e.name_or_index);
1421
+ if (cb == null && LISTENER_CALLBACK_PROPS.has(p))
1422
+ cb = e.toNode;
1423
+ else if (ctx == null && LISTENER_CONTEXT_PROPS.has(p))
1424
+ ctx = e.toNode;
1425
+ if (cb != null && ctx != null)
1426
+ break;
1427
+ }
1428
+ if (cb != null && ctx != null) {
1429
+ found.push({ id: node.id, callback: cb.name, context: ctx.name });
1430
+ }
1431
+ });
1432
+ all = found;
1433
+ scratch[key] = all;
1434
+ }
1435
+ return callbackNamed == null
1436
+ ? all
1437
+ : all.filter(r => r.callback === callbackNamed);
1438
+ };
1439
+ /**
1440
+ * Detached nodes whose CLASS NAME contains `needle`.
1441
+ *
1442
+ * Note what this cannot do, because the reflexive attempt returns a clean
1443
+ * zero: a detached node's `name` is its element or Blink class — `Detached
1444
+ * EventListener`, `Detached blink::RegisteredEventListener`, `Detached
1445
+ * HTMLDivElement` — and never a `data-testid`. Measured on a real capture:
1446
+ * 908 detached nodes, whose top names were `Detached EventListener` (148),
1447
+ * `Detached blink::RegisteredEventListener` (148) and `Detached
1448
+ * V8EventListener` (146). Filtering these for an app-level testid matches
1449
+ * nothing on any heap. For "which UI element leaked", go through the
1450
+ * retainer path (`memlab_detached_dom` groups by nearest non-detached
1451
+ * dominator) rather than the node name.
1452
+ */
1453
+ const detachedNamed = (needle) => {
1454
+ const lowered = needle.toLowerCase();
1455
+ const out = [];
1456
+ snapshot.nodes.forEach((node) => {
1457
+ if (node.id <= 3)
1458
+ return;
1459
+ if (!node.is_detached && !node.name.startsWith('Detached '))
1460
+ return;
1461
+ if (!node.name.toLowerCase().includes(lowered))
1462
+ return;
1463
+ out.push({ id: node.id, name: node.name });
1464
+ });
1465
+ return out;
1466
+ };
1467
+ // Cached type -> ids index, mirroring the class index above, so a
1468
+ // second pass over "every closure" does not re-walk the heap.
1469
+ const buildTypeIndex = () => {
1470
+ const cached = scratch.__typeIndex;
1471
+ if (cached)
1472
+ return cached;
1473
+ const byType = new Map();
1474
+ snapshot.nodes.forEach((node) => {
1475
+ if (node.id <= 3)
1476
+ return;
1477
+ let a = byType.get(node.type);
1478
+ if (!a) {
1479
+ a = [];
1480
+ byType.set(node.type, a);
1481
+ }
1482
+ a.push(node.id);
1483
+ });
1484
+ scratch.__typeIndex = byType;
1485
+ return byType;
1486
+ };
1487
+ const nodesFromIds = (ids) => {
1488
+ const out = [];
1489
+ for (const id of ids) {
1490
+ const n = snapshot.getNodeById(id);
1491
+ if (n)
1492
+ out.push(n);
1493
+ }
1494
+ return out;
1495
+ };
1496
+ const iterByClass = (name, opts) => nodesFromIds(byClass(name, opts)).map(wrapNode);
1497
+ // Same thing under the name people actually reach for. `byClass` returns
1498
+ // IDS, and not all of them resolve through `snapshot.getNodeById` — native
1499
+ // classes (AudioContext, OpusRecorder, Recorder) come back null, so the
1500
+ // reflexive `byClass(x).map(id => getNodeById(id).referrers)` throws
1501
+ // "Cannot read properties of null" on the first attempt, every time. This
1502
+ // returns node objects and skips the unresolvable ones.
1503
+ const nodesByClass = iterByClass;
1504
+ const iterByType = (type) => nodesFromIds(buildTypeIndex().get(type) ?? []).map(wrapNode);
1505
+ // One-pass class histogram, cached, optionally filtered. `byClass`
1506
+ // answers "where are the X"; this answers "what is in here at all",
1507
+ // which otherwise means a full manual walk every time.
1508
+ const classCounts = (opts) => {
1509
+ const cacheKey = '__classCounts';
1510
+ let all = scratch[cacheKey];
1511
+ if (!all) {
1512
+ const acc = new Map();
1513
+ snapshot.nodes.forEach((node) => {
1514
+ if (node.id <= 3)
1515
+ return;
1516
+ const key = `${node.type}::${node.name}`;
1517
+ const e = acc.get(key);
1518
+ if (e) {
1519
+ e.count++;
1520
+ e.selfSize += node.self_size;
1521
+ }
1522
+ else {
1523
+ acc.set(key, {
1524
+ name: node.name,
1525
+ type: node.type,
1526
+ count: 1,
1527
+ selfSize: node.self_size,
1528
+ });
1529
+ }
1530
+ });
1531
+ all = [...acc.values()].sort((a, b) => b.count - a.count);
1532
+ scratch[cacheKey] = all;
1533
+ }
1534
+ const matches = makeNamePatternTest(opts?.pattern);
1535
+ const minCount = opts?.minCount ?? 1;
1536
+ return all.filter(r => r.count >= minCount &&
1537
+ (opts?.type == null || r.type === opts.type) &&
1538
+ matches(r.name));
1539
+ };
1540
+ // Walk a dotted path of edges, reporting WHERE it stopped.
1541
+ //
1542
+ // The one-level-off mistake is the most common way an eval produces a
1543
+ // clean-looking wrong answer: a probe tested `_PSD` on an LRU wrapper when
1544
+ // the field lived on `wrapper.value`, got 0 across 300 samples, and the
1545
+ // zero was reported before the level was rechecked. `edgeTarget` chained by
1546
+ // hand hides that, because a null halfway looks the same as a null at the
1547
+ // end.
1548
+ const derefPath = (nodeOrId, path) => {
1549
+ let cur = typeof nodeOrId === 'number'
1550
+ ? snapshot.getNodeById(nodeOrId)
1551
+ : unwrapNode(nodeOrId);
1552
+ if (cur == null)
1553
+ return { found: false, stoppedAt: '(start)', node: null };
1554
+ const parts = path.split('.').filter(Boolean);
1555
+ const walked = [];
1556
+ for (const part of parts) {
1557
+ let next = null;
1558
+ const node = cur;
1559
+ for (const e of node.references) {
1560
+ if (e.type === 'hidden')
258
1561
  continue;
259
- const elem = edge.toNode;
260
- if (elem.id <= 3)
1562
+ if (String(e.name_or_index) === part) {
1563
+ next = e.toNode;
1564
+ break;
1565
+ }
1566
+ }
1567
+ if (next == null) {
1568
+ return {
1569
+ found: false,
1570
+ stoppedAt: walked.length > 0 ? walked.join('.') : '(start)',
1571
+ missingEdge: part,
1572
+ node: null,
1573
+ available: [...node.references]
1574
+ .filter(e => e.type === 'property')
1575
+ .slice(0, 20)
1576
+ .map(e => String(e.name_or_index)),
1577
+ };
1578
+ }
1579
+ walked.push(part);
1580
+ cur = next;
1581
+ }
1582
+ return { found: true, stoppedAt: walked.join('.'), node: wrapNode(cur) };
1583
+ };
1584
+ // "Is there anything matching this within N hops?" — answers the question
1585
+ // derefPath needs you to already know the answer to.
1586
+ const findWithin = (nodeOrId, edgeName, opts) => {
1587
+ const start = typeof nodeOrId === 'number'
1588
+ ? snapshot.getNodeById(nodeOrId)
1589
+ : unwrapNode(nodeOrId);
1590
+ if (start == null)
1591
+ return [];
1592
+ const maxDepth = Math.max(1, Math.min(opts?.maxDepth ?? 3, 6));
1593
+ const seen = new Set([start.id]);
1594
+ const hits = [];
1595
+ const queue = [
1596
+ { node: start, path: '', depth: 0 },
1597
+ ];
1598
+ while (queue.length > 0 && hits.length < 25) {
1599
+ const item = queue.shift();
1600
+ if (!item)
1601
+ break;
1602
+ for (const e of item.node.references) {
1603
+ if (e.type === 'hidden')
261
1604
  continue;
262
- total++;
263
- for (const propEdge of elem.references) {
264
- if (String(propEdge.name_or_index) === propertyName) {
265
- uniqueIds.add(propEdge.toNode.id);
1605
+ const name = String(e.name_or_index);
1606
+ const path = item.path ? `${item.path}.${name}` : name;
1607
+ if (name === edgeName) {
1608
+ hits.push({ path, id: e.toNode.id, name: e.toNode.name });
1609
+ if (hits.length >= 25)
266
1610
  break;
267
- }
1611
+ }
1612
+ if (item.depth + 1 < maxDepth && !seen.has(e.toNode.id)) {
1613
+ seen.add(e.toNode.id);
1614
+ queue.push({ node: e.toNode, path, depth: item.depth + 1 });
268
1615
  }
269
1616
  }
270
- return { uniqueCount: uniqueIds.size, totalElements: total };
271
- };
272
- const helpers = {
273
- serializeNodeSummary,
274
- serializeNodeDetail,
275
- formatBytes,
276
- formatNumber,
277
- markdownTable,
278
- isNodeWorthInspecting,
279
- filterLargestObjects,
280
- queryNodes,
281
- groupReferrersByEdge,
282
- groupArrayElementsByProperty,
283
- isOrphaned,
284
- countUniqueTargets,
285
- retainedSize,
286
- retainedSizes,
1617
+ }
1618
+ return hits;
1619
+ };
1620
+ // The node behind a named edge. Written from scratch in four separate
1621
+ // evals because `props()` returns {ref,name,type} wrappers, which are
1622
+ // awkward exactly when the node itself is what you need.
1623
+ const edgeTarget = (nodeOrId, edgeName) => {
1624
+ const n = typeof nodeOrId === 'number'
1625
+ ? snapshot.getNodeById(nodeOrId)
1626
+ : unwrapNode(nodeOrId);
1627
+ if (n == null)
1628
+ return null;
1629
+ for (const e of n.references) {
1630
+ if (e.type === 'hidden')
1631
+ continue;
1632
+ if (String(e.name_or_index) !== edgeName)
1633
+ continue;
1634
+ return e.toNode.id > 3 ? wrapNode(e.toNode) : null;
1635
+ }
1636
+ return null;
1637
+ };
1638
+ // Walk a repeated linked structure and say HOW it ended.
1639
+ //
1640
+ // Hand-written versions of this loop are the single most reliable way to
1641
+ // publish a wrong number from an eval. React's `queue.pending` is CIRCULAR,
1642
+ // so `while (next && next.id !== start)` with a `hops < 800` guard reported
1643
+ // `longestNextChain: 800` — the cap, presented as a measurement. The true
1644
+ // length was 2,066, and nothing in the output distinguished "the list ended"
1645
+ // from "I stopped counting". The discriminator is the point of this helper:
1646
+ // `terminated: 'cap'` means the number is a floor, not a length.
1647
+ const walkChain = (startOrNode, edgeName, opts) => {
1648
+ const maxHops = Math.max(1, opts?.maxHops ?? 100000);
1649
+ const start = typeof startOrNode === 'number'
1650
+ ? snapshot.getNodeById(startOrNode)
1651
+ : unwrapNode(startOrNode);
1652
+ if (start == null) {
1653
+ throw new Error(`walkChain: start node not found`);
1654
+ }
1655
+ const seen = new Set();
1656
+ const ids = [];
1657
+ let cur = start;
1658
+ let terminated = 'end';
1659
+ while (cur != null) {
1660
+ if (seen.has(cur.id)) {
1661
+ terminated = 'cycle';
1662
+ break;
1663
+ }
1664
+ if (seen.size >= maxHops) {
1665
+ terminated = 'cap';
1666
+ break;
1667
+ }
1668
+ seen.add(cur.id);
1669
+ if (opts?.collectIds)
1670
+ ids.push(cur.id);
1671
+ let nextNode = null;
1672
+ for (const e of cur.references) {
1673
+ if (String(e.name_or_index) !== edgeName)
1674
+ continue;
1675
+ nextNode = e.toNode.id > 3 ? e.toNode : null;
1676
+ break;
1677
+ }
1678
+ cur = nextNode;
1679
+ }
1680
+ return {
1681
+ length: seen.size,
1682
+ terminated,
1683
+ truncated: terminated === 'cap',
1684
+ ...(opts?.collectIds ? { ids } : {}),
287
1685
  };
288
- const sandbox = {
289
- snapshot: wrapSnapshot(snapshot),
290
- utils,
291
- helpers,
292
- console: capturedConsole,
293
- result: undefined,
294
- // Standard JS globals
295
- Array,
296
- Object,
297
- Map,
298
- Set,
299
- JSON,
300
- Math,
301
- RegExp,
302
- String,
303
- Number,
304
- Boolean,
305
- Date,
306
- Error,
307
- TypeError,
308
- RangeError,
309
- WeakMap,
310
- WeakSet,
311
- Symbol,
312
- parseInt,
313
- parseFloat,
314
- isNaN,
315
- isFinite,
316
- Infinity,
317
- NaN,
318
- undefined,
1686
+ };
1687
+ // Generic container walk. `mapEntries` / `setElements` cover Map and
1688
+ // Set; WeakMap tables and plain arrays needed a manual `references`
1689
+ // walk with hole filtering every time.
1690
+ const entries = (nodeOrId) => {
1691
+ const n = typeof nodeOrId === 'number'
1692
+ ? snapshot.getNodeById(nodeOrId)
1693
+ : unwrapNode(nodeOrId);
1694
+ if (n == null)
1695
+ return [];
1696
+ if (n.name === 'Map' || n.name === 'WeakMap') {
1697
+ return enumerateMapEntries(n).map(e => ({
1698
+ key: wrapNode(e.key),
1699
+ value: e.value == null ? null : wrapNode(e.value),
1700
+ }));
1701
+ }
1702
+ if (n.name === 'Set' || n.name === 'WeakSet') {
1703
+ return enumerateSetElements(n).map(el => ({
1704
+ key: null,
1705
+ value: wrapNode(el),
1706
+ }));
1707
+ }
1708
+ const out = [];
1709
+ for (const e of n.references) {
1710
+ if (e.type === 'hidden')
1711
+ continue;
1712
+ const name = String(e.name_or_index);
1713
+ if (name === '__proto__')
1714
+ continue;
1715
+ // V8 hangs the hidden class off an internal edge literally named
1716
+ // `map`; dropping it unconditionally also dropped a real property
1717
+ // named `map` (a config object with a `.map` field), which then
1718
+ // read as "the object does not have one".
1719
+ if (name === 'map' && e.type !== 'property')
1720
+ continue;
1721
+ if (e.type === 'element') {
1722
+ out.push({
1723
+ key: Number(e.name_or_index),
1724
+ value: wrapNode(e.toNode),
1725
+ });
1726
+ }
1727
+ else if (name === 'elements' && e.type === 'internal') {
1728
+ for (const el of e.toNode.references) {
1729
+ if (el.type !== 'element')
1730
+ continue;
1731
+ out.push({
1732
+ key: Number(el.name_or_index),
1733
+ value: wrapNode(el.toNode),
1734
+ });
1735
+ }
1736
+ }
1737
+ else if (e.type === 'property') {
1738
+ out.push({ key: name, value: wrapNode(e.toNode) });
1739
+ }
1740
+ }
1741
+ return out;
1742
+ };
1743
+ // What does this node actually own? The question behind
1744
+ // memlab_dominator_attribution, exposed for ad-hoc populations.
1745
+ const dominates = (id, opts) => {
1746
+ requireRetention('dominates');
1747
+ const limit = opts?.limit ?? 1000;
1748
+ const pop = opts?.population;
1749
+ let count = 0;
1750
+ let selfSize = 0;
1751
+ const ids = [];
1752
+ let truncated = false;
1753
+ snapshot.nodes.forEach((node) => {
1754
+ if (node.id <= 3 || node.id === id)
1755
+ return;
1756
+ if (pop != null && !pop(wrapNode(node)))
1757
+ return;
1758
+ let cur = node.dominatorNode ?? null;
1759
+ let hops = 0;
1760
+ while (cur && hops++ < 500) {
1761
+ if (cur.id === id) {
1762
+ count++;
1763
+ selfSize += node.self_size;
1764
+ if (ids.length < limit)
1765
+ ids.push(node.id);
1766
+ else
1767
+ truncated = true;
1768
+ break;
1769
+ }
1770
+ const next = cur.dominatorNode ?? null;
1771
+ if (!next || next.id === cur.id)
1772
+ break;
1773
+ cur = next;
1774
+ }
1775
+ });
1776
+ return { count, selfSize, ids, truncated };
1777
+ };
1778
+ // Walk up the dominator chain to the nearest node that carries a class
1779
+ // identity, skipping V8's containers and system objects. "Who owns this?"
1780
+ // is a loop every investigation rewrites by hand — and writes slightly
1781
+ // differently each time, which is why the same population gets attributed
1782
+ // to different owners on different days.
1783
+ //
1784
+ // A single-letter name is NOT skipped. In a minified bundle `t` and `e`
1785
+ // are the only class identity that exists; treating them as meaningless
1786
+ // walks straight past the owner and reports the system container above it.
1787
+ // Pair the name with `memlab_identify` to find out what it is.
1788
+ const CONTAINER_OWNER = /^(Object|Array|system(\s*\/.*)?|\(.*\))$/;
1789
+ const owner = (nodeOrId, opts) => {
1790
+ requireRetention('owner');
1791
+ const start = typeof nodeOrId === 'number'
1792
+ ? snapshot.getNodeById(nodeOrId)
1793
+ : unwrapNode(nodeOrId);
1794
+ if (start == null)
1795
+ return null;
1796
+ const maxHops = opts?.maxHops ?? 50;
1797
+ let cur = start.dominatorNode ?? null;
1798
+ let last = null;
1799
+ let hops = 1;
1800
+ let lastHops = 0;
1801
+ while (cur != null && cur.id > 3 && hops <= maxHops) {
1802
+ last = cur;
1803
+ lastHops = hops;
1804
+ if (!CONTAINER_OWNER.test(cur.name)) {
1805
+ return {
1806
+ id: cur.id,
1807
+ name: cur.name,
1808
+ type: cur.type,
1809
+ hops,
1810
+ selfSize: cur.self_size,
1811
+ named: true,
1812
+ };
1813
+ }
1814
+ const next = cur.dominatorNode ?? null;
1815
+ if (next == null || next.id === cur.id)
1816
+ break;
1817
+ cur = next;
1818
+ hops++;
1819
+ }
1820
+ // React fallback. Hooks, update queues and fibers are all plain `Object`,
1821
+ // so the container filter above walks straight past every one of them and
1822
+ // reports nothing: on one population `recordsByOwner` came back
1823
+ // `[["(none)", 1645]]` for 100% of the records. A fiber is recognisable
1824
+ // by its own fields rather than by its class name, and its component name
1825
+ // is the answer the caller actually wanted.
1826
+ const fiber = nearestFiber(start, maxHops);
1827
+ if (fiber != null) {
1828
+ const componentName = fiberComponentName(fiber);
1829
+ return {
1830
+ id: fiber.id,
1831
+ name: componentName ?? fiber.name,
1832
+ type: fiber.type,
1833
+ hops: 0,
1834
+ selfSize: fiber.self_size,
1835
+ named: componentName != null,
1836
+ };
1837
+ }
1838
+ // Nothing but containers all the way up is itself the answer — report
1839
+ // the furthest node reached with named:false rather than null, which
1840
+ // would be indistinguishable from "no such node".
1841
+ if (last == null)
1842
+ return null;
1843
+ return {
1844
+ id: last.id,
1845
+ name: last.name,
1846
+ type: last.type,
1847
+ hops: lastHops,
1848
+ selfSize: last.self_size,
1849
+ named: false,
319
1850
  };
320
- const context = vm.createContext(sandbox);
321
- const script = new vm.Script(code, { filename: 'memlab_eval' });
322
- script.runInContext(context, { timeout: timeout_ms });
323
- // Actionable hint when nothing was assigned to `result` (the #1 user
324
- // error code that `return`s a value or runs a value-returning IIFE
325
- // never populates `result`, so output is silently "undefined").
326
- if (sandbox.result === undefined && consoleOutput.length === 0) {
327
- return toolResult('Your code ran without error but never assigned to `result`, so there is nothing to return.\n' +
328
- 'Assign the value you want back to `result` (do NOT use `return` at the top level), e.g.:\n' +
329
- ' `result = someValue;`\n' +
330
- 'Use mode:"describe_env" to see the full calling convention.');
1851
+ };
1852
+ // Elements backing store, read correctly. Five separate facts are needed
1853
+ // (owner-vs-store edge split, SMI invisibility, unmeasurability, slot-width
1854
+ // calibration, dictionary detection) and every one of them was got wrong
1855
+ // once before it was got right see src/heap-shapes.ts. Hand-deriving this
1856
+ // in an eval is how an all-SMI array reads as 100% wasted.
1857
+ const elements = (nodeOrId) => {
1858
+ const n = typeof nodeOrId === 'number'
1859
+ ? snapshot.getNodeById(nodeOrId)
1860
+ : unwrapNode(nodeOrId);
1861
+ if (n == null)
1862
+ return null;
1863
+ return readElementsInfo(snapshot, n);
1864
+ };
1865
+ // DISTINCT nodes pointed at by an edge with this name — the mirror of
1866
+ // `withProp`, which finds nodes that HAVE the property. Asking "what is
1867
+ // stored under `.logs` anywhere in the heap" previously needed a full scan.
1868
+ //
1869
+ // Deduplicated because a shared target reached from N referrers is one
1870
+ // storage site, not N: the obvious use ("how many distinct places hold a
1871
+ // `.logs`") would otherwise multiply-count every shared array, and nothing
1872
+ // in the returned ids says which of them were duplicates.
1873
+ const byReferrerEdge = (edgeName) => {
1874
+ const hits = new Set();
1875
+ snapshot.nodes.forEach(node => {
1876
+ for (const e of node.references) {
1877
+ if (String(e.name_or_index) !== edgeName)
1878
+ continue;
1879
+ if (e.toNode.id > 3)
1880
+ hits.add(e.toNode.id);
1881
+ break;
1882
+ }
1883
+ });
1884
+ return [...hits];
1885
+ };
1886
+ // Group-and-count over ids. The single most-rewritten block in ad-hoc eval
1887
+ // code, and the one whose hand-written versions most often silently drop
1888
+ // the undefined bucket.
1889
+ const histogram = (ids, keyFn, opts) => {
1890
+ const counts = new Map();
1891
+ for (const id of ids) {
1892
+ const node = snapshot.getNodeById(id);
1893
+ if (node == null)
1894
+ continue;
1895
+ const raw = keyFn(wrapNode(node), id);
1896
+ const key = raw == null ? '(none)' : String(raw);
1897
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1898
+ }
1899
+ const rows = [...counts.entries()]
1900
+ .map(([key, count]) => ({ key, count }))
1901
+ .sort((a, b) => b.count - a.count);
1902
+ return opts?.limit != null ? rows.slice(0, opts.limit) : rows;
1903
+ };
1904
+ // Shortest reference path a -> b, by BFS over outgoing edges. Bounded,
1905
+ // and reports that it gave up rather than returning null as if no path
1906
+ // existed.
1907
+ const pathBetween = (fromId, toId, opts) => {
1908
+ const maxNodes = opts?.maxNodes ?? 200_000;
1909
+ const start = snapshot.getNodeById(fromId);
1910
+ if (start == null || snapshot.getNodeById(toId) == null) {
1911
+ return { found: false, exhausted: false, path: [] };
1912
+ }
1913
+ // `seen` is seeded with `fromId`, so the BFS below can never reach it
1914
+ // again and the trivial 0-hop path would come back as "no path".
1915
+ if (fromId === toId) {
1916
+ return {
1917
+ found: true,
1918
+ exhausted: false,
1919
+ path: [`@${fromId} ${start.name}`],
1920
+ };
331
1921
  }
332
- let output;
333
- try {
334
- output = JSON.stringify(sandbox.result, null, 2) ?? 'undefined';
1922
+ const prev = new Map();
1923
+ const seen = new Set([fromId]);
1924
+ let queue = [start];
1925
+ let visited = 0;
1926
+ while (queue.length > 0) {
1927
+ const next = [];
1928
+ for (const node of queue) {
1929
+ if (++visited > maxNodes) {
1930
+ return { found: false, exhausted: true, path: [] };
1931
+ }
1932
+ for (const e of node.references) {
1933
+ const t = e.toNode;
1934
+ if (t.id <= 3 || seen.has(t.id))
1935
+ continue;
1936
+ seen.add(t.id);
1937
+ prev.set(t.id, { via: String(e.name_or_index), from: node.id });
1938
+ if (t.id === toId) {
1939
+ const path = [];
1940
+ let cur = toId;
1941
+ while (cur !== fromId) {
1942
+ const p = prev.get(cur);
1943
+ if (p == null)
1944
+ break;
1945
+ const n = snapshot.getNodeById(cur);
1946
+ path.unshift(`.${p.via} -> @${cur} ${n?.name ?? ''}`);
1947
+ cur = p.from;
1948
+ }
1949
+ path.unshift(`@${fromId} ${start.name}`);
1950
+ return { found: true, exhausted: false, path };
1951
+ }
1952
+ next.push(t);
1953
+ }
1954
+ }
1955
+ queue = next;
335
1956
  }
336
- catch {
337
- output = String(sandbox.result);
1957
+ return { found: false, exhausted: false, path: [] };
1958
+ };
1959
+ // Named result sets are SESSION-scoped, not snapshot-scoped: comparing
1960
+ // a baseline scan against a final scan is the whole job, and the old
1961
+ // per-snapshot scratch dropped the baseline the moment the next rung
1962
+ // was loaded — exactly when it was needed.
1963
+ //
1964
+ // Node ids, however, are per-capture. A set of ids saved against one
1965
+ // snapshot means nothing against another, so a cross-snapshot load is
1966
+ // refused unless the caller opts in. Counts and strings are portable;
1967
+ // ids are not, and silently letting them through is the failure this
1968
+ // whole class of guard exists to prevent.
1969
+ const save = (name, value) => {
1970
+ setSavedResult(name, value, currentHandle);
1971
+ return value;
1972
+ };
1973
+ const load = (name, opts) => {
1974
+ const entry = getSavedResult(name);
1975
+ if (entry == null) {
1976
+ throw new Error(`No saved result named "${name}". Saved names: ${savedNames().join(', ') || '(none)'}. ` +
1977
+ 'Save one with the save_as parameter or helpers.save(name, value).');
338
1978
  }
339
- output = truncate(output, MAX_OUTPUT_SIZE);
340
- if (consoleOutput.length > 0) {
341
- const consolePart = truncate(consoleOutput.join('\n'), MAX_OUTPUT_SIZE - output.length > 1024 ? 4096 : 1024);
342
- output += '\n\n--- console output ---\n' + consolePart;
1979
+ if (entry.handle !== currentHandle && opts?.allowCrossSnapshot !== true) {
1980
+ throw new Error(`"${name}" was saved against snapshot "${entry.handle}" and the current snapshot is "${currentHandle}". Node ids are per-capture, so ids from another snapshot resolve to unrelated objects or to nothing. ` +
1981
+ 'If the value is counts/strings/shapes rather than ids, pass {allowCrossSnapshot: true} to read it anyway.');
343
1982
  }
344
- return toolResult(output);
1983
+ return entry.value;
1984
+ };
1985
+ // Cross-SESSION persistence. `save`/`load` above are scoped to the current
1986
+ // snapshot and dropped when it is unloaded, which is right for an id list
1987
+ // (ids are per-capture) and wrong for a derived fact — a decoded cap, a
1988
+ // per-entry cost, a conclusion. Those are what a later session needs and
1989
+ // the only thing that can meaningfully outlive the heap they came from.
1990
+ const remember = (name, value) => {
1991
+ writeEvalStore({ ...readEvalStore(), [name]: value });
1992
+ return value;
1993
+ };
1994
+ const recall = (name) => {
1995
+ const store = readEvalStore();
1996
+ return name == null ? Object.keys(store) : store[name];
1997
+ };
1998
+ // Evenly-spaced sampling, not random: two calls over the same population
1999
+ // return the same members, so a follow-up question lands on the objects
2000
+ // the first answer described. Math.random() here would silently make
2001
+ // every re-run a different measurement.
2002
+ const sample = (items, n) => {
2003
+ const arr = Array.isArray(items) ? items : [...items];
2004
+ if (n <= 0 || arr.length === 0)
2005
+ return [];
2006
+ if (arr.length <= n)
2007
+ return arr.slice();
2008
+ const step = arr.length / n;
2009
+ const out = [];
2010
+ for (let i = 0; i < n; i++)
2011
+ out.push(arr[Math.floor(i * step)]);
2012
+ return out;
2013
+ };
2014
+ const listSaved = () => listSavedResults();
2015
+ // Sandbox code holds PROXIED nodes (see wrapNode). The helpers below
2016
+ // read `retainedSize`, which the proxy refuses on purpose, so they take
2017
+ // the real node: unwrap at the boundary rather than making every caller
2018
+ // remember which helpers are proxy-safe.
2019
+ // The retention-bearing fields of a serialized node, blanked on a LIGHT
2020
+ // snapshot. The dominator pass never ran there, so they serialize as 0 /
2021
+ // null-by-accident — a confident wrong number. Blanked rather than
2022
+ // refused because everything else these two return (id, name, type,
2023
+ // self size, edge/referrer counts, string value) is genuinely available
2024
+ // on a light load, and refusing would take that away too.
2025
+ const blankRetentionOnLight = (s) => {
2026
+ if (!light)
2027
+ return { ...s };
2028
+ const out = { ...s, retained_size: null };
2029
+ if ('dominator_id' in s)
2030
+ out.dominator_id = null;
2031
+ return out;
2032
+ };
2033
+ const helpersImpl = {
2034
+ serializeNodeSummary: (n) => blankRetentionOnLight(serializeNodeSummary(unwrapNode(n))),
2035
+ serializeNodeDetail: (n) => blankRetentionOnLight(serializeNodeDetail(unwrapNode(n))),
2036
+ formatBytes,
2037
+ formatNumber,
2038
+ markdownTable,
2039
+ isNodeWorthInspecting: (n, ...rest) => isNodeWorthInspecting(unwrapNode(n), ...rest),
2040
+ // Both of these RANK by `node.retainedSize`, so both need the real
2041
+ // snapshot and a non-light load. The previous wrapper renamed the
2042
+ // first parameter `nodes` and mapped it as an array, which matched
2043
+ // neither utility's signature — `filterLargestObjects(snapshot,
2044
+ // filter, limit)` — and did nothing. Unwrapping is what was actually
2045
+ // needed: sandbox code only ever holds the PROXIED snapshot, whose
2046
+ // nodes refuse the very `retainedSize` read these two rank on.
2047
+ //
2048
+ // `RETENTION_IDENTIFIERS` catches `filterLargestObjects` textually
2049
+ // before the code runs; these runtime guards cover both, and cover
2050
+ // them precisely (a `queryNodes` count needs no retention at all).
2051
+ filterLargestObjects: (snap, filter, limit) => {
2052
+ requireRetention('filterLargestObjects');
2053
+ return filterLargestObjects(unwrapNode(snap), filter, limit);
2054
+ },
2055
+ queryNodes: (snap, filter, opts) => {
2056
+ if (opts?.outputMode !== 'count')
2057
+ requireRetention('queryNodes');
2058
+ return queryNodes(unwrapNode(snap), filter, opts);
2059
+ },
2060
+ groupReferrersByEdge,
2061
+ groupArrayElementsByProperty,
2062
+ isOrphaned,
2063
+ countUniqueTargets,
2064
+ retainedSize,
2065
+ retainedSizes,
2066
+ nodeBrief,
2067
+ mapEntries,
2068
+ setElements,
2069
+ props,
2070
+ getProp,
2071
+ ownProps,
2072
+ shapeKeys,
2073
+ hasShape,
2074
+ rootPath,
2075
+ shapeSignature,
2076
+ byClass,
2077
+ byTypename,
2078
+ withProp,
2079
+ aggregateRetained,
2080
+ contextOf,
2081
+ closureCensus,
2082
+ listenerRecords,
2083
+ detachedNamed,
2084
+ isRealDetached,
2085
+ iterByClass,
2086
+ nodesByClass,
2087
+ iterByType,
2088
+ classCounts,
2089
+ edgeTarget,
2090
+ walkChain,
2091
+ elements,
2092
+ byReferrerEdge,
2093
+ derefPath,
2094
+ findWithin,
2095
+ entries,
2096
+ dominates,
2097
+ owner,
2098
+ histogram,
2099
+ pathBetween,
2100
+ save,
2101
+ load,
2102
+ listSaved,
2103
+ remember,
2104
+ recall,
2105
+ sample,
2106
+ };
2107
+ /**
2108
+ * A mistyped helper name used to fail as `helpers.foo is not a function`,
2109
+ * with no clue what the right name was. The surface is 40+ helpers and
2110
+ * `describe_env` is a separate round trip that costs ~10 KB of tokens, so
2111
+ * the cheapest fix is to answer the question at the point it is asked.
2112
+ */
2113
+ const helperNames = Object.keys(helpersImpl).sort();
2114
+ lastKnownHelperNames = helperNames;
2115
+ const helpers = new Proxy(helpersImpl, {
2116
+ get(target, prop, receiver) {
2117
+ if (typeof prop === 'string' && !(prop in target)) {
2118
+ const lower = prop.toLowerCase();
2119
+ const near = helperNames.filter(n => n.toLowerCase().includes(lower) ||
2120
+ lower.includes(n.toLowerCase()) ||
2121
+ editDistanceWithin(n.toLowerCase(), lower, 2));
2122
+ throw new Error(`helpers.${prop} does not exist.` +
2123
+ (near.length > 0
2124
+ ? ` Did you mean: ${near.slice(0, 5).join(', ')}?`
2125
+ : '') +
2126
+ ` All helpers: ${helperNames.join(', ')}.` +
2127
+ ' Use mode:"describe_env" for signatures.');
2128
+ }
2129
+ return Reflect.get(target, prop, receiver);
2130
+ },
2131
+ });
2132
+ const sandbox = {
2133
+ snapshot: wrapSnapshot(snapshot, budget),
2134
+ utils,
2135
+ helpers,
2136
+ console: capturedConsole,
2137
+ result: undefined,
2138
+ // Standard JS globals
2139
+ Array,
2140
+ Object,
2141
+ Map,
2142
+ Set,
2143
+ JSON,
2144
+ Math,
2145
+ RegExp,
2146
+ String,
2147
+ Number,
2148
+ Boolean,
2149
+ Date,
2150
+ Error,
2151
+ TypeError,
2152
+ RangeError,
2153
+ WeakMap,
2154
+ WeakSet,
2155
+ Symbol,
2156
+ parseInt,
2157
+ parseFloat,
2158
+ isNaN,
2159
+ isFinite,
2160
+ Infinity,
2161
+ NaN,
2162
+ undefined,
2163
+ };
2164
+ const context = vm.createContext(sandbox);
2165
+ const script = new vm.Script(code, { filename: 'memlab_eval' });
2166
+ // A budget abort is a controlled stop, not a failure: whatever the code
2167
+ // had already assigned to `result` is still returned, annotated below.
2168
+ let wallClockTimedOut = false;
2169
+ try {
2170
+ script.runInContext(context, { timeout: timeout_ms });
345
2171
  }
346
2172
  catch (err) {
347
- return errorResult(new Error(actionableEvalError(err, code)));
2173
+ // A wall-clock timeout used to discard everything and return only
2174
+ // "Execution timed out", while a `max_nodes` overrun returned the partial
2175
+ // value with a warning. That asymmetry is the expensive one: a probe that
2176
+ // ran 120s, was backgrounded and died at 600s produced ZERO information,
2177
+ // which is a strong argument against ever writing an ambitious eval
2178
+ // again. V8 terminates the script but the sandbox keeps whatever was
2179
+ // already assigned, so the partial value is right there.
2180
+ if (isScriptTimeout(err) && sandbox.result !== undefined) {
2181
+ wallClockTimedOut = true;
2182
+ }
2183
+ else if (!(err instanceof BudgetExceeded)) {
2184
+ // Keyed on the error itself, never on `budget.exceeded`: code that
2185
+ // catches the abort and then throws for an unrelated reason must
2186
+ // still surface that error.
2187
+ throw err;
2188
+ }
348
2189
  }
349
- });
2190
+ // Actionable hint when nothing was assigned to `result` (the #1 user
2191
+ // error — code that `return`s a value or runs a value-returning IIFE
2192
+ // never populates `result`, so output is silently "undefined").
2193
+ if (sandbox.result === undefined &&
2194
+ consoleOutput.length === 0 &&
2195
+ !budget.exceeded) {
2196
+ return toolResult('Your code ran without error but never assigned to `result`, so there is nothing to return.\n' +
2197
+ 'Assign the value you want back to `result` (do NOT use `return` at the top level), e.g.:\n' +
2198
+ ' `result = someValue;`\n' +
2199
+ 'Use mode:"describe_env" to see the full calling convention.');
2200
+ }
2201
+ // `undefined` is never worth persisting: on reload it is
2202
+ // indistinguishable from a name that was never saved, and the usual
2203
+ // cause is the "never assigned to `result`" mistake — which the hint
2204
+ // above only catches when the run produced no console output.
2205
+ const nothingToSave = sandbox.result === undefined;
2206
+ if (save_as != null &&
2207
+ !budget.exceeded &&
2208
+ !wallClockTimedOut &&
2209
+ !nothingToSave) {
2210
+ setSavedResult(save_as, sandbox.result, currentHandle);
2211
+ }
2212
+ const budgetBytes = max_result_bytes ?? MAX_OUTPUT_SIZE;
2213
+ const shrunk = shrinkResult(sandbox.result, budgetBytes);
2214
+ let output;
2215
+ try {
2216
+ output = JSON.stringify(shrunk.value, null, 2) ?? 'undefined';
2217
+ }
2218
+ catch {
2219
+ output = String(shrunk.value);
2220
+ }
2221
+ // Pretty-printing adds indentation the byte budget above did not account
2222
+ // for, so the string clamp stays as a backstop; it should rarely fire now.
2223
+ output = truncate(output, budgetBytes * 2);
2224
+ if (consoleOutput.length > 0) {
2225
+ const consolePart = truncate(consoleOutput.join('\n'), MAX_OUTPUT_SIZE - output.length > 1024 ? 4096 : 1024);
2226
+ output += '\n\n--- console output ---\n' + consolePart;
2227
+ }
2228
+ const footer = [];
2229
+ const heapBytes = getSnapshotMetadata()?.totalSize ?? 0;
2230
+ const implausible = implausibleByteFields(sandbox.result, heapBytes);
2231
+ if (implausible.length > 0) {
2232
+ footer.push(`⚠️ IMPLAUSIBLE TOTAL — ${implausible
2233
+ .map(h => `\`${h.path}\` = ${formatBytes(h.bytes)}`)
2234
+ .join(', ')} exceeds the whole snapshot (${formatBytes(heapBytes)}). ` +
2235
+ 'A byte total larger than the heap is a DOUBLE-COUNT, not a big number. The usual cause is walking a ' +
2236
+ 'Map/Set backing `table`, which yields KEYS as well as values and whose members are frequently shared ' +
2237
+ 'across many containers, so the same node is measured once per container. Deduplicate by node id ' +
2238
+ '(`const seen = new Set()`) and re-run. If you meant to sum overlapping retained subtrees, use ' +
2239
+ '`helpers.aggregateRetained(ids)` for the dominator-deduplicated figure.');
2240
+ }
2241
+ // Attached to the RESULT of the call that hand-rolled a built-in, because
2242
+ // that is the one moment the caller is guaranteed to read. Never suppresses
2243
+ // or alters the value above it.
2244
+ const hintText = code != null ? formatEvalHints(hintsForEval(code)) : null;
2245
+ if (hintText != null)
2246
+ footer.push(hintText);
2247
+ if (shrunk.truncated) {
2248
+ const nothingFit = shrunk.keptEntries === 0 && shrunk.droppedEntries > 0;
2249
+ footer.push(`⚠️ truncated: true — the result exceeded ${formatNumber(budgetBytes)} bytes` +
2250
+ (nothingFit
2251
+ ? `, and NOT EVEN ONE of the ${formatNumber(shrunk.droppedEntries)} entries fit inside it, ` +
2252
+ 'so the value above is EMPTY. Nothing was kept — do not read it as a leading subset. ' +
2253
+ 'A single entry is larger than the whole budget, so raise `max_result_bytes` ' +
2254
+ 'substantially or return less per entry.'
2255
+ : shrunk.droppedEntries > 0
2256
+ ? `, so the last ${formatNumber(shrunk.droppedEntries)} entr${shrunk.droppedEntries === 1 ? 'y was' : 'ies were'} dropped. ` +
2257
+ 'Entries are dropped whole, so what is shown is still valid and still the leading rows — ' +
2258
+ 'sort your result before assigning it if the ones you want are not first.'
2259
+ : '.') +
2260
+ ' Raise `max_result_bytes`, or aggregate in the eval instead of returning raw rows.');
2261
+ }
2262
+ if (wallClockTimedOut) {
2263
+ footer.push(`⚠️ WALL-CLOCK TIMEOUT after ${formatNumber(timeout_ms)}ms — the value above is PARTIAL. ` +
2264
+ 'It is whatever your code had assigned to `result` when the script was terminated, so treat every count in ' +
2265
+ 'it as a FLOOR, not a measurement. Raise `timeout_ms`, narrow the scan with an indexed helper ' +
2266
+ '(`helpers.byClass` / `byTypename` / `withProp` / `byReferrerEdge`), or run `dry_run: true` first — a nested ' +
2267
+ 'walk over `.references` inside a `nodes.forEach` costs edge-visits, not node-visits.');
2268
+ if (save_as != null) {
2269
+ footer.push(`Not saved as "${save_as}" — a partial result would be indistinguishable from a complete one on reload.`);
2270
+ }
2271
+ }
2272
+ if (budget.exceeded) {
2273
+ // A partial walk that also produced NO value is not a degraded answer, it
2274
+ // is no answer — and it prints as a bare `undefined`, which is exactly
2275
+ // what a legitimate empty census looks like. Refuse it instead: the
2276
+ // accumulator is usually assigned after the loops, so this is the common
2277
+ // shape of the failure, not an edge case.
2278
+ const producedNothing = sandbox.result === undefined || sandbox.result === null;
2279
+ if (producedNothing) {
2280
+ return errorResult(`Walk aborted after ${formatNumber(budget.max)} node visits (max_nodes) and the code assigned no \`result\`. ` +
2281
+ 'Refusing to return the empty value: an aborted walk that produced nothing is indistinguishable from a genuine empty result, ' +
2282
+ 'and reads as "this pattern is not in the heap".\n\n' +
2283
+ `This snapshot has ${formatNumber(getSnapshotMetadata()?.nodeCount ?? 0)} nodes, so the default budget allows ~6 full passes. ` +
2284
+ 'Either raise `max_nodes`, narrow the scan with an indexed helper (`helpers.byClass` / `byTypename` / `withProp`) instead of a full ' +
2285
+ '`snapshot.nodes` walk, or assign to `result` incrementally so a partial answer is still meaningful — then re-run.');
2286
+ }
2287
+ footer.push(`⚠️ Walk aborted after ${formatNumber(budget.max)} node visits (max_nodes). The value above is PARTIAL. ` +
2288
+ 'Raise max_nodes, or narrow the scan with an indexed helper (`helpers.byClass` / `byTypename` / `withProp`) instead of a full `snapshot.nodes` walk.');
2289
+ if (save_as != null) {
2290
+ footer.push(`Not saved as "${save_as}" — a partial result would be indistinguishable from a complete one on reload.`);
2291
+ }
2292
+ }
2293
+ else if (budget.visited > 0) {
2294
+ footer.push(`nodes_visited: ${formatNumber(budget.visited)}`);
2295
+ const stride = budget.sampleEvery ?? 1;
2296
+ if (stride > 1) {
2297
+ footer.push(`⚠️ ESTIMATE — sampled 1-in-${formatNumber(stride)}: the walk considered ` +
2298
+ `${formatNumber(budget.seen ?? 0)} nodes and ran your callback on ` +
2299
+ `${formatNumber(budget.visited)} of them. **Counts above are roughly ` +
2300
+ `${formatNumber(stride)}x low**; multiply to estimate, and re-run with ` +
2301
+ '`sample: 1` before recording any number. A ZERO here is not absence — ' +
2302
+ `a population smaller than ~${formatNumber(stride)} is easily missed entirely.`);
2303
+ }
2304
+ // A whole-heap walk that matched NOTHING is reported as a confident
2305
+ // negative — "there are no closures with captured scopes" — when the
2306
+ // overwhelmingly likelier cause is a predicate that cannot match.
2307
+ //
2308
+ // The edge-type filter is the canonical way to get here: a JSFunction's
2309
+ // hop to its Context is an `internal` edge NAMED `context`, so the
2310
+ // reflexive `e.type === 'context'` matches zero edges on every heap and
2311
+ // returns a clean 0 with no error. That silent zero is worse than a
2312
+ // throw, because nothing in the output suggests re-checking the filter.
2313
+ if ((budget.sampleEvery ?? 1) === 1 &&
2314
+ budget.visited >= ZERO_MATCH_WALK_THRESHOLD &&
2315
+ isEmptyCensusResult(sandbox.result)) {
2316
+ footer.push(`⚠️ ZERO matches over ${formatNumber(budget.visited)} node visits. This may be a real negative — ` +
2317
+ 'but a whole-heap walk that matches nothing is more often a predicate that cannot match. ' +
2318
+ 'Check the edge-type filter first: a closure→scope hop is an `internal` edge NAMED `context` ' +
2319
+ "(`e.name_or_index === 'context'`), NOT `e.type === 'context'`; `props()` adds provenance keys so " +
2320
+ 'shape tests must use `helpers.shapeKeys`/`hasShape`; and `byClass` needs the exact class name. ' +
2321
+ 'Confirm with a deliberately broad version of the same predicate before recording this as "not present".');
2322
+ }
2323
+ }
2324
+ if (save_as != null && !budget.exceeded) {
2325
+ footer.push(nothingToSave
2326
+ ? `Not saved as "${save_as}" — \`result\` was undefined, and a saved \`undefined\` is indistinguishable from a name that was never saved. Assign the value you want to keep to \`result\` (do NOT \`return\` at the top level) and re-run.`
2327
+ : `Saved as "${save_as}" — read it back in a later call with \`helpers.load("${save_as}")\`.`);
2328
+ }
2329
+ if (footer.length > 0) {
2330
+ output += '\n\n--- ' + footer.join('\n');
2331
+ }
2332
+ return toolResult(output);
2333
+ }
2334
+ catch (err) {
2335
+ return errorResult(new Error(actionableEvalError(err, code)));
2336
+ }
2337
+ }
2338
+ /**
2339
+ * True for the `vm` module's wall-clock timeout.
2340
+ *
2341
+ * Keyed on `err.code`, which is a documented Node error code, with the message
2342
+ * text only as a fallback. Matching the prose alone couples salvaging a partial
2343
+ * result to V8's exact wording: a reworded message would silently stop matching
2344
+ * and quietly revert to throwing away the partial value, which is the behaviour
2345
+ * this branch exists to prevent.
2346
+ */
2347
+ function isScriptTimeout(err) {
2348
+ // Deliberately NOT `err instanceof Error`. `vm` raises this from the script's
2349
+ // OWN realm, whose `Error` is a different constructor, so a cross-realm
2350
+ // instanceof is false — measured, not assumed. Gating on it made the salvage
2351
+ // below unreachable: every wall-clock timeout fell through to `throw err` and
2352
+ // the partial `result` was discarded, which is the exact behaviour this
2353
+ // branch was written to end.
2354
+ if (err == null || typeof err !== 'object')
2355
+ return false;
2356
+ const e = err;
2357
+ if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT')
2358
+ return true;
2359
+ return (typeof e.message === 'string' &&
2360
+ e.message.includes('Script execution timed out'));
350
2361
  }
351
2362
  // Map the opaque VM errors that the documented calling-convention mistakes
352
2363
  // produce into actionable guidance (Feedback §3).
353
2364
  function actionableEvalError(err, code) {
354
2365
  const msg = err instanceof Error ? err.message : String(err);
355
- if (msg.includes('Script execution timed out')) {
2366
+ if (isScriptTimeout(err)) {
356
2367
  return `Execution timed out. Increase timeout_ms, or narrow the scan (filter earlier, use a dedicated tool like memlab_find_by_property/memlab_property_distribution instead of a full snapshot.nodes walk).`;
357
2368
  }
358
2369
  if (msg.includes('Illegal return statement')) {
@@ -367,7 +2378,269 @@ function actionableEvalError(err, code) {
367
2378
  }
368
2379
  return msg;
369
2380
  }
370
- function describeEnv() {
2381
+ function savedNames() {
2382
+ return listSavedResults()
2383
+ .map(r => r.name)
2384
+ .sort();
2385
+ }
2386
+ /** One-line shape description so `list_saved` is useful without re-dumping the data. */
2387
+ function describeSavedValue(value) {
2388
+ if (value === null)
2389
+ return 'null';
2390
+ if (Array.isArray(value))
2391
+ return `array (${formatNumber(value.length)} items)`;
2392
+ if (typeof value === 'string') {
2393
+ return `string (${formatNumber(value.length)} chars)`;
2394
+ }
2395
+ if (typeof value === 'object') {
2396
+ // The sandbox is seeded with the host realm's Map/Set/Date/typed arrays, so
2397
+ // `instanceof` holds for values built inside eval. Without these cases every
2398
+ // one of them reports `object (0 keys)` — a container holding millions of
2399
+ // entries would look empty here.
2400
+ if (value instanceof Map) {
2401
+ return `Map (${formatNumber(value.size)} entries)`;
2402
+ }
2403
+ if (value instanceof Set) {
2404
+ return `Set (${formatNumber(value.size)} items)`;
2405
+ }
2406
+ if (value instanceof Date) {
2407
+ return `Date (${value.toISOString()})`;
2408
+ }
2409
+ if (ArrayBuffer.isView(value)) {
2410
+ const ctor = value.constructor?.name ?? 'TypedArray';
2411
+ const len = value.length;
2412
+ return typeof len === 'number'
2413
+ ? `${ctor} (${formatNumber(len)} items)`
2414
+ : `${ctor} (${formatNumber(value.byteLength)} bytes)`;
2415
+ }
2416
+ const keys = formatNumber(Object.keys(value).length);
2417
+ // A named constructor (WeakMap, a class instance, …) is reported by name so
2418
+ // a `0 keys` line is attributable rather than just puzzling.
2419
+ const ctor = value.constructor?.name;
2420
+ return ctor == null || ctor === 'Object'
2421
+ ? `object (${keys} keys)`
2422
+ : `${ctor} (${keys} own keys)`;
2423
+ }
2424
+ return typeof value;
2425
+ }
2426
+ function describeSaved() {
2427
+ const saved = listSavedResults();
2428
+ if (saved.length === 0) {
2429
+ return [
2430
+ '# Saved result sets: (none)',
2431
+ '',
2432
+ 'Save one by passing `save_as: "<name>"` on a memlab_eval call, or calling `helpers.save("<name>", value)` inside your code.',
2433
+ 'Read it back in a later call with `helpers.load("<name>")`.',
2434
+ 'Saved sets last for the SERVER SESSION and survive loading another snapshot, so a baseline scan can be compared against a later rung. The snapshot each was saved against is recorded: reading one back under a different snapshot is refused unless you pass `{allowCrossSnapshot: true}`, because node ids are per-capture.',
2435
+ ].join('\n');
2436
+ }
2437
+ const current = getCurrentHandle();
2438
+ return [
2439
+ `# Saved result sets (${saved.length}) — session-scoped`,
2440
+ '',
2441
+ markdownTable(['name', 'saved against', 'shape'], saved.map(r => [
2442
+ r.name,
2443
+ r.handle === current ? `${r.handle} (current)` : r.handle,
2444
+ describeSavedValue(getSavedResult(r.name)?.value),
2445
+ ])),
2446
+ '',
2447
+ 'Read one back with `helpers.load("<name>")`. Ids saved against another snapshot need `helpers.load("<name>", {allowCrossSnapshot: true})` — and are only meaningful if the value is counts/strings/shapes rather than node ids.',
2448
+ ].join('\n');
2449
+ }
2450
+ /**
2451
+ * A result that says "none of them" — 0, `[]`, `{}`, or an empty Map/Set.
2452
+ *
2453
+ * `undefined`/`null` are deliberately NOT included: those mean "the code
2454
+ * assigned nothing", which is a different failure and is already handled.
2455
+ */
2456
+ export function isEmptyCensusResult(v) {
2457
+ if (v === 0)
2458
+ return true;
2459
+ if (Array.isArray(v))
2460
+ return v.length === 0;
2461
+ if (v instanceof Map || v instanceof Set)
2462
+ return v.size === 0;
2463
+ if (v != null && typeof v === 'object') {
2464
+ return Object.keys(v).length === 0;
2465
+ }
2466
+ return false;
2467
+ }
2468
+ /**
2469
+ * How many node visits make a zero result worth questioning.
2470
+ *
2471
+ * Below this the caller probably scanned an indexed subset and legitimately
2472
+ * found nothing; above it they walked the whole graph and got no match, which
2473
+ * is far more often a wrong predicate than an empty heap.
2474
+ */
2475
+ const ZERO_MATCH_WALK_THRESHOLD = 100000;
2476
+ /**
2477
+ * Property names that make an object look like an event-listener record. Kept
2478
+ * identical to `stale-collections.ts` on purpose: two tools disagreeing about
2479
+ * what a listener record IS produces two incomparable counts of the same thing.
2480
+ */
2481
+ const LISTENER_CALLBACK_PROPS = new Set([
2482
+ 'callback',
2483
+ 'fn',
2484
+ 'handler',
2485
+ 'listener',
2486
+ ]);
2487
+ const LISTENER_CONTEXT_PROPS = new Set([
2488
+ 'context',
2489
+ 'ctx',
2490
+ 'this',
2491
+ 'target',
2492
+ 'scope',
2493
+ ]);
2494
+ /** Helper names mentioned as `helpers.x(` anywhere in the describe_env text. */
2495
+ function documentedHelperNames() {
2496
+ const doc = describeEnvLines().join('\n');
2497
+ return [
2498
+ ...new Set([...doc.matchAll(/helpers\.([A-Za-z_][A-Za-z0-9_]*)/g)].map(m => m[1])),
2499
+ ].sort();
2500
+ }
2501
+ /**
2502
+ * Syntax-check an eval and report what it references — WITHOUT a snapshot.
2503
+ *
2504
+ * The only way to discover a typo in a 40-line eval used to be to pay a 2–4
2505
+ * minute snapshot load first, which is enough friction that most exploratory
2506
+ * ideas were never written down. Everything here is decidable from the source
2507
+ * text alone, so the cost of a wrong guess drops from minutes to seconds.
2508
+ */
2509
+ function lintEval(code, knownHelpers) {
2510
+ const lines = ['## memlab_eval lint — nothing was executed', ''];
2511
+ if (code.trim() === '') {
2512
+ return 'Pass `code` to lint. Nothing to check.';
2513
+ }
2514
+ let syntaxOk = true;
2515
+ try {
2516
+ // Compiling does not run anything; it is the same parse the real call does.
2517
+ new vm.Script(code, { filename: 'memlab_eval_lint' });
2518
+ lines.push('- **Syntax:** parses cleanly.');
2519
+ }
2520
+ catch (err) {
2521
+ syntaxOk = false;
2522
+ lines.push(`- **Syntax ERROR:** ${err instanceof Error ? err.message : String(err)}`);
2523
+ if (err instanceof SyntaxError &&
2524
+ /Illegal return/.test(err.message ?? '')) {
2525
+ lines.push(' Assign to `result` instead of using a top-level `return`.');
2526
+ }
2527
+ }
2528
+ if (!/\bresult\s*=/.test(code)) {
2529
+ lines.push('- ⚠ **No assignment to `result`** — the call will run and return nothing. This is the single most common eval mistake.');
2530
+ }
2531
+ const referenced = [
2532
+ ...new Set([...code.matchAll(/helpers\.([A-Za-z_][A-Za-z0-9_]*)/g)].map(m => m[1])),
2533
+ ].sort();
2534
+ if (referenced.length > 0) {
2535
+ lines.push(`- **Helpers referenced:** ${referenced.join(', ')}.`);
2536
+ // On a fresh server the live table has not been built yet (it needs a
2537
+ // snapshot). The documented list is the next best source and is always
2538
+ // available — worded as "not documented" so an undocumented-but-real helper
2539
+ // is not reported as a typo.
2540
+ const documented = documentedHelperNames();
2541
+ const table = knownHelpers ?? documented;
2542
+ const authoritative = knownHelpers != null;
2543
+ {
2544
+ const unknown = referenced.filter(n => !table.includes(n));
2545
+ if (unknown.length === 0) {
2546
+ lines.push(authoritative
2547
+ ? ' All exist.'
2548
+ : ' All are documented (checked against `describe_env`; this server has not built the live helper table yet).');
2549
+ }
2550
+ else {
2551
+ for (const name of unknown) {
2552
+ const lower = name.toLowerCase();
2553
+ const near = table.filter(n => n.toLowerCase().includes(lower) ||
2554
+ lower.includes(n.toLowerCase()) ||
2555
+ editDistanceWithin(n.toLowerCase(), lower, 2));
2556
+ lines.push(` - ❌ \`helpers.${name}\` does not exist.` +
2557
+ (near.length > 0
2558
+ ? ` Did you mean ${near.slice(0, 4).join(', ')}?`
2559
+ : ''));
2560
+ }
2561
+ }
2562
+ }
2563
+ }
2564
+ const fullWalk = /\b(?:snapshot\.)?(?:nodes|edges)\s*\.\s*forEach/.test(code);
2565
+ const nesting = maxTraversalNesting(code);
2566
+ if (fullWalk) {
2567
+ lines.push(`- **Full-heap walk:** yes${nesting > 0 ? `, with a nested \`.references\`/\`.referrers\` traversal at depth ${nesting}` : ''}.` +
2568
+ (nesting >= 2
2569
+ ? ' At depth 2 or more the cost is edge-visits and typically runs for minutes — run `dry_run: true` against the loaded snapshot for a concrete estimate.'
2570
+ : ''));
2571
+ }
2572
+ else {
2573
+ lines.push('- **Full-heap walk:** none detected by text match.');
2574
+ }
2575
+ if (/for\s*\(\s*const\s+\w+\s+of\s+snapshot\.nodes/.test(code)) {
2576
+ lines.push('- ❌ `snapshot.nodes` is NOT for-of iterable. Use `snapshot.nodes.forEach(node => { ... })`.');
2577
+ }
2578
+ if (/\.\s*retained_?[Ss]ize\b/.test(code) &&
2579
+ !/helpers\.retainedSize/.test(code)) {
2580
+ lines.push('- ⚠ `node.retainedSize` / `node.retained_size` THROW inside eval (they read back ~0 on some loads). Use `helpers.retainedSize(id)` / `helpers.retainedSizes([ids])` / `helpers.aggregateRetained([ids])`.');
2581
+ }
2582
+ lines.push('', syntaxOk
2583
+ ? '_Re-run with `mode:"eval"` (the default) to execute._'
2584
+ : '_Fix the syntax error first._');
2585
+ return lines.join('\n');
2586
+ }
2587
+ function describeEnv(section) {
2588
+ const all = describeEnvLines();
2589
+ if (section == null || section.trim() === '')
2590
+ return all.join('\n');
2591
+ const wanted = section.trim().toLowerCase();
2592
+ // Split on `## ` headings and keep the ones that match. The calling
2593
+ // conventions always travel with the answer: they are what an eval gets wrong
2594
+ // when it is written from a partial read of this document.
2595
+ const blocks = [];
2596
+ let current = [];
2597
+ for (const line of all) {
2598
+ if (line.startsWith('## ')) {
2599
+ if (current.length > 0)
2600
+ blocks.push(current);
2601
+ current = [line];
2602
+ }
2603
+ else {
2604
+ current.push(line);
2605
+ }
2606
+ }
2607
+ if (current.length > 0)
2608
+ blocks.push(current);
2609
+ const headings = blocks
2610
+ .map(b => b[0])
2611
+ .filter(h => h.startsWith('## '))
2612
+ .map(h => h.slice(3));
2613
+ const isConventions = (heading) => heading.toLowerCase().includes('calling convention');
2614
+ const required = blocks.filter(b => isConventions(b[0]));
2615
+ const matched = blocks.filter(b => b[0].startsWith('## ') &&
2616
+ b[0].toLowerCase().includes(wanted) &&
2617
+ !isConventions(b[0]));
2618
+ // Naming the calling conventions is not a miss. That block is kept out of
2619
+ // `matched` only because `required` always emits it, so treating it as
2620
+ // unmatched answered `section: "calling"` — or "convention", or the
2621
+ // "(REQUIRED)" in its own heading — with `no section matches`, for the one
2622
+ // section guaranteed to be in every reply.
2623
+ const wantedConventions = required.some(b => b[0].toLowerCase().includes(wanted));
2624
+ if (matched.length === 0 && !wantedConventions) {
2625
+ return [
2626
+ `# memlab_eval environment — no section matches "${section}"`,
2627
+ '',
2628
+ `Sections: ${headings.map(h => `"${h}"`).join(', ')}.`,
2629
+ 'Omit `section` for the whole document.',
2630
+ ].join('\n');
2631
+ }
2632
+ return [
2633
+ '# memlab_eval environment (filtered)',
2634
+ '',
2635
+ ...required.flatMap(b => [...b, '']),
2636
+ ...matched.flatMap(b => [...b, '']),
2637
+ `_Other sections: ${headings
2638
+ .filter(h => !isConventions(h) && !matched.some(b => b[0].slice(3) === h))
2639
+ .map(h => `"${h}"`)
2640
+ .join(', ')}. Omit \`section\` for all of them._`,
2641
+ ].join('\n');
2642
+ }
2643
+ function describeEnvLines() {
371
2644
  return [
372
2645
  '# memlab_eval environment',
373
2646
  '',
@@ -382,21 +2655,74 @@ function describeEnv() {
382
2655
  '- `helpers` — `serializeNodeSummary`, `serializeNodeDetail`, `formatBytes`, `formatNumber`, `markdownTable`, `isNodeWorthInspecting`, `filterLargestObjects`, `queryNodes`, `groupReferrersByEdge(nodeId)`, `groupArrayElementsByProperty(arrayNodeId, prop)`, `isOrphaned(nodeId, ownerEdges[])`, `countUniqueTargets(arrayNodeId, prop)`, `retainedSize(id) -> number`, `retainedSizes(ids[]) -> Record<id, bytes>` (an OBJECT keyed by id, NOT an array — use `sizes[id]` or `Object.values(sizes)`, not `.reduce`/`.map` directly).',
383
2656
  '- Standard JS built-ins (Array, Object, Map, Set, JSON, Math, RegExp, …). No require/process/fs/network.',
384
2657
  '',
2658
+ '## Collection / shape / index helpers (prefer these over hand-rolling)',
2659
+ '- `helpers.mapEntries(mapId, limit=1000) -> [{key, value}]` and `helpers.setElements(setId, limit=1000) -> [brief]` — CORRECT Map/Set/WeakMap enumeration. Handles browser `internal`-typed backing slots and SMI-value gaps (naive `type === "element"` filtering or positional `[i],[i+1]` pairing silently returns 0 / mispairs). Each brief is `{id, name, type, self_size, retained_size, string}`.',
2660
+ "- `helpers.props(nodeOrId) -> {prop: scalar | {ref, name, type}}` and `helpers.getProp(nodeOrId, name)` — read an object's own properties without the `for (const e of n.references) …` boilerplate. Number-valued props surface as a ref to a `smi number`/`heap number` node; their actual numeric value is not in the snapshot format. ⚠️ **`props()` is for INSPECTION, not for SHAPE MATCHING** — on a node with no `property` edges it falls back to a named internal/shortcut/hidden edge walk and adds `length`/`map`/`__via`/`__note`, so a shape test written against `Object.keys(props(id))` returns ZERO matches on objects that plainly have the shape. Use the next line for that.",
2661
+ '- `helpers.shapeKeys(nodeOrId) -> Set<string>`, `helpers.ownProps(nodeOrId) -> {…}`, `helpers.hasShape(nodeOrId, ["a","b"], {exact?, exclude?}) -> boolean` — own JS properties ONLY (`property` edges, no `__proto__`, no fallback, no provenance keys). **This is the correct way to ask "what shape is this object".** `hasShape(id, ["element","record"], {exact: true})` is the whole test.',
2662
+ '- `helpers.rootPath(nodeOrId, {maxHops?}) -> [{id, name, type, edge}]` — the GC-root path for one node, root first, exactly as `memlab_retainer_trace` walks it. Saves hand-writing the `while (cur.hasPathEdge) cur = cur.pathEdge.fromNode` loop inside a larger eval (which gets rewritten, slightly differently, every time a probe needs to name an owner).',
2663
+ '- `helpers.shapeSignature(nodeOrId, {maxStringLen?}) -> string` — stable shallow content signature (sorted prop names + scalar values) for duplicate-record detection. Numeric values are NOT captured (see `memlab_duplicate_objects`), so records differing only in a number field hash the same.',
2664
+ '- `helpers.byClass(name, {type?}) -> ids[]`, `helpers.byTypename(name) -> ids[]`, `helpers.withProp(name) -> ids[]` — INDEXED id lookups. The class/typename index is built once per snapshot and memoized in a session scratch, so a follow-up call is index-speed, not another full `snapshot.nodes` scan. `byClass` indexes EVERY node type (closure, string, array, native, …), matching `memlab_find_nodes_by_class`; pass `{type: "object"}` to narrow. `byTypename` is object-only because `__typename` is a JS property. (See also the `memlab_duplicate_objects` tool for a ready-made dedup report.)',
2665
+ '- `helpers.nodesByClass(name, {type?}) -> node[]` (alias of `iterByClass`) — the same lookup returning NODE OBJECTS. Prefer it over `byClass`: ids from `byClass` are not all resolvable through `snapshot.getNodeById` — native classes such as `AudioContext` / `OpusRecorder` come back null — so the reflexive `byClass(x).map(id => getNodeById(id).referrers)` throws `Cannot read properties of null` and needs defensive `if (!n) continue` boilerplate on every native-touching eval.',
2666
+ '- `helpers.iterByClass(name, {type?}) -> node[]` / `helpers.iterByType(type) -> node[]` — indexed iteration; no full scan, index built once per snapshot.',
2667
+ '- `helpers.classCounts({pattern?, type?, minCount?}) -> [{name, type, count, selfSize}]` — one-pass class histogram, cached; `pattern` is a case-insensitive regex (substring fallback).',
2668
+ '- `helpers.entries(nodeOrId) -> [{key, value}]` — generic container walk: Map/WeakMap (paired, SMI gaps handled), Set/WeakSet, Array (both direct `element` edges and the `(object elements)` backing store), plain object properties. Holes and `__proto__`/`map` are filtered.',
2669
+ '- `helpers.derefPath(nodeOrId, "value._PSD.trans") -> {found, stoppedAt, missingEdge?, available?, node}` — walk a dotted edge path and, on failure, say WHICH hop failed and what was there instead. Use this instead of chaining edgeTarget: a null halfway looks identical to a null at the end, which is how a probe tests the wrong level and reports a confident zero.',
2670
+ '- `helpers.findWithin(nodeOrId, edgeName, {maxDepth}) -> [{path,id,name}]` — is this property anywhere within N hops, and at what path? Answers "which level is it on?" in one call.',
2671
+ '- `helpers.edgeTarget(nodeOrId, edgeName) -> node | null` — the node behind a named edge, when you need the node and not the `{ref,name,type}` wrapper `props()` returns.',
2672
+ '- `helpers.walkChain(startOrNode, edgeName, {maxHops?, collectIds?}) -> {length, terminated: "cycle" | "end" | "cap", truncated}` — walk a linked structure (`.next` update queues, `.prev` closure chains, LRU lists) and report HOW it ended. Use this rather than a hand-written loop: React update queues are CIRCULAR, and a hand-rolled `while (next && next.id !== start)` with a hop guard reports the guard as the length. A measured case printed 800 for a chain of 2,066. `terminated: "cap"` means the length is a floor. For the full per-link report (what each link captures, distinct vs repeated) use the `memlab_chain_walk` tool.',
2673
+ '- `helpers.isRealDetached(node) -> boolean` — the oddball/root filtering the detached-DOM tools apply internally, so hand-written eval counts the same set they do.',
2674
+ '- `helpers.dominates(id, {population?, limit?}) -> {count, selfSize, ids, truncated}` — what this node actually owns (bounded 500-hop dominator walk). `population` is a predicate over nodes.',
2675
+ '- `helpers.owner(idOrNode, {maxHops?}) -> {id, name, type, hops, selfSize, named} | null` — nearest dominator carrying a class identity, skipping V8 containers (`Object`, `Array`, `system / …`, `(closure)`). Minified single-letter names are KEPT: in a production bundle they are the only identity there is — pair with `memlab_identify`. `named:false` means the walk found only containers and is reporting the furthest node reached.',
2676
+ '- `helpers.remember(name, value)` / `helpers.recall(name?)` — persist a derived fact to disk (`~/.memlab/eval-store.json`, override with `MEMLAB_STATE_DIR`) and read it back in a LATER session. `save`/`load` are per-snapshot and dropped on unload, which is right for id lists (ids are per-capture) and wrong for a conclusion. `recall()` with no name lists the keys.',
2677
+ '- `helpers.sample(items, n) -> items[]` — evenly-spaced sample, NOT random: two calls over the same population return the same members, so a follow-up question lands on the objects the first answer described.',
2678
+ '- `helpers.histogram(ids, keyFn, {limit?}) -> [{key, count}]` — group-and-count over ids, sorted by count; a null/undefined key becomes `(none)` rather than being dropped.',
2679
+ '- `helpers.pathBetween(fromId, toId, {maxNodes?}) -> {found, exhausted, path[]}` — BFS over outgoing edges; `exhausted:true` means the budget ran out, which is NOT the same as "no path".',
2680
+ '- `helpers.save(name, value)` / `helpers.load(name, {allowCrossSnapshot?})` / `helpers.listSaved()` — named result sets, SESSION-scoped: they survive loading another snapshot, which is what makes a baseline-vs-final comparison possible. The snapshot each was saved against is recorded, and a cross-snapshot read is refused unless you opt in — node ids are per-capture and mean nothing in another snapshot.',
2681
+ '- `helpers.aggregateRetained(ids[]) -> {retained, exact}` — dominator-deduped retained size for a SET of ids (does not double-count when one id dominates another); `exact:false` means the bounded walk was truncated (upper bound).',
2682
+ '',
2683
+ '## Populations that get hand-rolled every round (use these instead)',
2684
+ "Each of these was rewritten by hand in round after round, slightly differently each time — which makes two rounds' numbers incomparable for reasons that have nothing to do with the app, and in one case (the edge filter) returns a confident zero.",
2685
+ '- `helpers.detachedNamed(substr) -> [{id, name}]` — detached nodes whose CLASS NAME contains `substr`, with the same oddball/root filtering the detached-DOM tools apply. ⚠️ A detached node\'s name is its element or Blink class (`Detached EventListener`, `Detached blink::RegisteredEventListener`, `Detached HTMLDivElement`) and **never a `data-testid`** — filtering these for an app-level testid matches nothing on any heap. For "which UI element leaked", use `memlab_detached_dom`, which groups by nearest non-detached dominator.',
2686
+ '- `helpers.listenerRecords(callbackName?) -> [{id, callback, context}]` — objects carrying BOTH a callback-ish and a context-ish property, i.e. event-listener records. Optionally narrowed to one callback class, which is how the question is actually asked ("how many `subscribe_$0` records are held?"). Cached; the definition matches `memlab_stale_collections` exactly.',
2687
+ '- `helpers.contextOf(nodeOrId) -> node | null` — the scope a closure captured, e.g. `system / Context / scope @767271`. **Do not hand-roll this**: the hop is an `internal` edge NAMED `context`, not a `context`-TYPED edge, so the reflexive filter returns null on every closure in the heap (see the edge-type section below). Returns null for a non-closure — note `helpers.byClass`/`nodesByClass` also match the class-NAME STRING node, so filter on `type === "closure"` before asking for a scope.',
2688
+ '- `helpers.closureCensus({minCount?, pattern?}) -> [{name, count, withScope}]` — closure classes with how many instances captured a scope. `count` alone cannot separate "1,000 copies of a function" from "1,000 copies each pinning a distinct scope", and only the second is a retention story. Cached.',
2689
+ '',
2690
+ '## Named result sets (multi-step exploration)',
2691
+ 'Keep intermediate sets SERVER-SIDE instead of round-tripping them through the transcript — the ids never have to be printed, so a long investigation costs a fraction of the tokens.',
2692
+ '- `save_as: "<name>"` (tool parameter) — saves this call\'s `result` under that name after it completes. An `undefined` `result` is NOT saved (it would be indistinguishable from an unsaved name); the response says so.',
2693
+ '- `helpers.save(name, value) -> value` — save mid-script (returns the value, so it composes inline).',
2694
+ '- `helpers.load(name) -> value` — read a saved set back in a later call. Throws with the list of known names if it does not exist.',
2695
+ '- `helpers.listSaved() -> names[]`, or call the tool with `mode:"list_saved"` for names + shapes.',
2696
+ 'Save plain data (ids, counts, strings) — NOT node objects or proxies. Sets are scoped to the current snapshot and dropped when it is unloaded, so a saved id list can never be read against the wrong snapshot.',
2697
+ 'Typical shape: call 1 `save_as:"candidates"` builds the id list; call 2 does `const ids = helpers.load("candidates");` and measures them; call 3 traces only the survivors.',
2698
+ '',
2699
+ '## Traversal budget',
2700
+ 'Every call reports `nodes_visited`. Pass `max_nodes` to bound a `snapshot.nodes.forEach` walk: on overrun the walk aborts and the PARTIAL `result` is returned with a warning rather than failing, so a broad exploratory scan is safe to attempt. A partial result is never saved by `save_as`.',
2701
+ '',
385
2702
  '## IHeapNode API',
386
- '`.id`, `.name`, `.type`, `.self_size`, `.retainedSize` (alias `.retained_size`), `.edge_count`, `.is_detached`, `.numOfReferrers` (alias `.referrer_count`), `.isString`, `.toStringNode()?.stringValue`, `.hasPathEdge`, `.pathEdge`, `.dominatorNode`, `.location` (`script_id`/`line`/`column`).',
2703
+ '`.id`, `.name`, `.type`, `.self_size`, `.edge_count`, `.is_detached`, `.numOfReferrers` (alias `.referrer_count`), `.isString`, `.toStringNode()?.stringValue`, `.hasPathEdge`, `.pathEdge`, `.dominatorNode`, `.location` (`script_id`/`line`/`column`).',
387
2704
  '',
388
- '## Caveat: retained_size',
389
- 'Inside eval, `.retainedSize`/`.retained_size` can read back ~0 for every node on some loads. Counts, property/edge walks, and string values are reliable. For authoritative retained sizes call `helpers.retainedSize(id)` (returns a number) or `helpers.retainedSizes([ids])` (returns a `Record<id, bytes>` OBJECT — not an array; iterate with `Object.values(sizes)` / index with `sizes[id]`) both re-resolve the node on the real snapshot, so you can rank custom analyses by retained size. Or use `memlab_largest_objects`, `memlab_class_histogram`, `memlab_pinch_points`, or `memlab_object_shape`.',
2705
+ '## `.retainedSize` THROWS here',
2706
+ '`node.retainedSize` / `node.retained_size` raise inside eval instead of returning a number. They have been observed reading back ~0 for every node on some loads while the same id read via `snapshot.getNodeById(id)` returns the true value — and a silently wrong number ranks a whole analysis wrongly with nothing in the output to say so. Use `helpers.retainedSize(id)` (number), `helpers.retainedSizes([ids])` (a `Record<id, bytes>` OBJECT — index it as `sizes[id]` or iterate `Object.values(sizes)`, do not `.map`/`.reduce` it directly), or `helpers.aggregateRetained([ids])` for a dominator-deduped total. `node.self_size` is read straight from the snapshot and is reliable.',
390
2707
  '',
391
2708
  '## IHeapEdge API',
392
2709
  '`.name_or_index`, `.type` (property/element/context/internal/hidden/shortcut), `.toNode`, `.fromNode`.',
393
2710
  '',
2711
+ '### Edge TYPE vs edge NAME — the silent-zero trap',
2712
+ 'These are different fields and the reflexive guess is wrong for the most-asked question. A closure and its captured scope are linked like this:',
2713
+ '```',
2714
+ "closure --(type: 'internal', name_or_index: 'context')--> 'system / Context / scope @<id>'",
2715
+ "'system / Context / scope @<id>' --(type: 'context', name_or_index: '<varName>')--> captured value",
2716
+ '```',
2717
+ "So the hop FROM a function TO its scope is an **`internal` edge named `context`** — filtering a function's `.references` on `e.type === 'context'` matches **zero edges on every heap** and returns a clean `0`. The `context` TYPE only appears on the edges INSIDE the scope object, one per captured variable. (Measured on a 2.9M-node browser capture: 37,791 `context`-NAMED edges out of closures, every one of them `internal`, and not a single `context`-TYPED edge among the 255,084 edges leaving closures; the scope objects they point at emit 60,513 `context`-typed edges between them.) Rule of thumb: match `e.name_or_index` for a specific named hop, `e.type` only for a category.",
2718
+ 'The same shape bites elsewhere: array backing stores hang off an `internal` edge named `elements`, and Map/Set contents live behind `internal` `table` — which is why `helpers.entries` / `mapEntries` exist.',
2719
+ '',
394
2720
  '## Runnable example',
395
2721
  '```',
396
2722
  'const counts = {};',
397
2723
  'snapshot.nodes.forEach(node => { counts[node.type] = (counts[node.type] || 0) + 1; });',
398
2724
  'result = counts;',
399
2725
  '```',
400
- ].join('\n');
2726
+ ];
401
2727
  }
402
2728
  //# sourceMappingURL=eval.js.map