@memlab/mcp-server 2.52.0 → 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 (277) hide show
  1. package/README.md +22 -2
  2. package/dist/anonymized-snapshot.d.ts +75 -0
  3. package/dist/anonymized-snapshot.d.ts.map +1 -0
  4. package/dist/anonymized-snapshot.js +101 -0
  5. package/dist/anonymized-snapshot.js.map +1 -0
  6. package/dist/eval-hints.d.ts +41 -0
  7. package/dist/eval-hints.d.ts.map +1 -0
  8. package/dist/eval-hints.js +115 -0
  9. package/dist/eval-hints.js.map +1 -0
  10. package/dist/heap-budget.d.ts +32 -0
  11. package/dist/heap-budget.d.ts.map +1 -0
  12. package/dist/heap-budget.js +90 -0
  13. package/dist/heap-budget.js.map +1 -0
  14. package/dist/heap-shapes.d.ts +148 -0
  15. package/dist/heap-shapes.d.ts.map +1 -0
  16. package/dist/heap-shapes.js +193 -0
  17. package/dist/heap-shapes.js.map +1 -0
  18. package/dist/heap-state.d.ts +20 -0
  19. package/dist/heap-state.d.ts.map +1 -1
  20. package/dist/heap-state.js +8 -1
  21. package/dist/heap-state.js.map +1 -1
  22. package/dist/index.js +64 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/react-shapes.d.ts +42 -0
  25. package/dist/react-shapes.d.ts.map +1 -0
  26. package/dist/react-shapes.js +149 -0
  27. package/dist/react-shapes.js.map +1 -0
  28. package/dist/server-version.d.ts +19 -0
  29. package/dist/server-version.d.ts.map +1 -0
  30. package/dist/server-version.js +19 -0
  31. package/dist/server-version.js.map +1 -0
  32. package/dist/snapshot-borrow.d.ts +67 -0
  33. package/dist/snapshot-borrow.d.ts.map +1 -0
  34. package/dist/snapshot-borrow.js +113 -0
  35. package/dist/snapshot-borrow.js.map +1 -0
  36. package/dist/string-index.d.ts.map +1 -1
  37. package/dist/string-index.js +8 -0
  38. package/dist/string-index.js.map +1 -1
  39. package/dist/tools/analyze-run.d.ts.map +1 -1
  40. package/dist/tools/analyze-run.js +79 -18
  41. package/dist/tools/analyze-run.js.map +1 -1
  42. package/dist/tools/anonymize-snapshot.d.ts +12 -0
  43. package/dist/tools/anonymize-snapshot.d.ts.map +1 -0
  44. package/dist/tools/anonymize-snapshot.js +159 -0
  45. package/dist/tools/anonymize-snapshot.js.map +1 -0
  46. package/dist/tools/app-config.d.ts +12 -0
  47. package/dist/tools/app-config.d.ts.map +1 -0
  48. package/dist/tools/app-config.js +217 -0
  49. package/dist/tools/app-config.js.map +1 -0
  50. package/dist/tools/app-heap.d.ts +18 -0
  51. package/dist/tools/app-heap.d.ts.map +1 -1
  52. package/dist/tools/app-heap.js +12 -46
  53. package/dist/tools/app-heap.js.map +1 -1
  54. package/dist/tools/artifact-budget.d.ts +12 -0
  55. package/dist/tools/artifact-budget.d.ts.map +1 -0
  56. package/dist/tools/artifact-budget.js +219 -0
  57. package/dist/tools/artifact-budget.js.map +1 -0
  58. package/dist/tools/async-census.d.ts +12 -0
  59. package/dist/tools/async-census.d.ts.map +1 -0
  60. package/dist/tools/async-census.js +243 -0
  61. package/dist/tools/async-census.js.map +1 -0
  62. package/dist/tools/auto-investigate.d.ts.map +1 -1
  63. package/dist/tools/auto-investigate.js +54 -5
  64. package/dist/tools/auto-investigate.js.map +1 -1
  65. package/dist/tools/batch.d.ts.map +1 -1
  66. package/dist/tools/batch.js +33 -5
  67. package/dist/tools/batch.js.map +1 -1
  68. package/dist/tools/cache-analysis.d.ts +15 -0
  69. package/dist/tools/cache-analysis.d.ts.map +1 -1
  70. package/dist/tools/cache-analysis.js +85 -10
  71. package/dist/tools/cache-analysis.js.map +1 -1
  72. package/dist/tools/census-diff.d.ts +55 -0
  73. package/dist/tools/census-diff.d.ts.map +1 -0
  74. package/dist/tools/census-diff.js +228 -0
  75. package/dist/tools/census-diff.js.map +1 -0
  76. package/dist/tools/chain-walk.d.ts +12 -0
  77. package/dist/tools/chain-walk.d.ts.map +1 -0
  78. package/dist/tools/chain-walk.js +184 -0
  79. package/dist/tools/chain-walk.js.map +1 -0
  80. package/dist/tools/collection-diff.d.ts +44 -0
  81. package/dist/tools/collection-diff.d.ts.map +1 -0
  82. package/dist/tools/collection-diff.js +343 -0
  83. package/dist/tools/collection-diff.js.map +1 -0
  84. package/dist/tools/collection-trend.d.ts.map +1 -1
  85. package/dist/tools/collection-trend.js +2 -2
  86. package/dist/tools/collection-trend.js.map +1 -1
  87. package/dist/tools/detached-dom.d.ts +3 -0
  88. package/dist/tools/detached-dom.d.ts.map +1 -1
  89. package/dist/tools/detached-dom.js +78 -3
  90. package/dist/tools/detached-dom.js.map +1 -1
  91. package/dist/tools/dev-artifacts.d.ts.map +1 -1
  92. package/dist/tools/dev-artifacts.js +221 -3
  93. package/dist/tools/dev-artifacts.js.map +1 -1
  94. package/dist/tools/diff-snapshots.d.ts.map +1 -1
  95. package/dist/tools/diff-snapshots.js +18 -2
  96. package/dist/tools/diff-snapshots.js.map +1 -1
  97. package/dist/tools/dom-audit.d.ts +37 -0
  98. package/dist/tools/dom-audit.d.ts.map +1 -0
  99. package/dist/tools/dom-audit.js +247 -0
  100. package/dist/tools/dom-audit.js.map +1 -0
  101. package/dist/tools/duplicated-strings.d.ts.map +1 -1
  102. package/dist/tools/duplicated-strings.js +3 -2
  103. package/dist/tools/duplicated-strings.js.map +1 -1
  104. package/dist/tools/eval-across.d.ts +12 -0
  105. package/dist/tools/eval-across.d.ts.map +1 -0
  106. package/dist/tools/eval-across.js +135 -0
  107. package/dist/tools/eval-across.js.map +1 -0
  108. package/dist/tools/eval.d.ts +78 -0
  109. package/dist/tools/eval.d.ts.map +1 -1
  110. package/dist/tools/eval.js +2270 -701
  111. package/dist/tools/eval.js.map +1 -1
  112. package/dist/tools/event-listener-leaks.d.ts.map +1 -1
  113. package/dist/tools/event-listener-leaks.js +42 -17
  114. package/dist/tools/event-listener-leaks.js.map +1 -1
  115. package/dist/tools/event-registry.d.ts.map +1 -1
  116. package/dist/tools/event-registry.js +42 -8
  117. package/dist/tools/event-registry.js.map +1 -1
  118. package/dist/tools/explain-delta.d.ts.map +1 -1
  119. package/dist/tools/explain-delta.js +61 -10
  120. package/dist/tools/explain-delta.js.map +1 -1
  121. package/dist/tools/finding-index.d.ts +181 -0
  122. package/dist/tools/finding-index.d.ts.map +1 -1
  123. package/dist/tools/finding-index.js +343 -24
  124. package/dist/tools/finding-index.js.map +1 -1
  125. package/dist/tools/get-value.d.ts +16 -0
  126. package/dist/tools/get-value.d.ts.map +1 -1
  127. package/dist/tools/get-value.js +96 -2
  128. package/dist/tools/get-value.js.map +1 -1
  129. package/dist/tools/hypothesis.d.ts.map +1 -1
  130. package/dist/tools/hypothesis.js +117 -58
  131. package/dist/tools/hypothesis.js.map +1 -1
  132. package/dist/tools/id-space-audit.d.ts +12 -0
  133. package/dist/tools/id-space-audit.d.ts.map +1 -0
  134. package/dist/tools/id-space-audit.js +262 -0
  135. package/dist/tools/id-space-audit.js.map +1 -0
  136. package/dist/tools/identify.d.ts +12 -0
  137. package/dist/tools/identify.d.ts.map +1 -0
  138. package/dist/tools/identify.js +205 -0
  139. package/dist/tools/identify.js.map +1 -0
  140. package/dist/tools/identity-diff.d.ts +12 -0
  141. package/dist/tools/identity-diff.d.ts.map +1 -0
  142. package/dist/tools/identity-diff.js +188 -0
  143. package/dist/tools/identity-diff.js.map +1 -0
  144. package/dist/tools/intern-opportunities.d.ts.map +1 -1
  145. package/dist/tools/intern-opportunities.js +4 -3
  146. package/dist/tools/intern-opportunities.js.map +1 -1
  147. package/dist/tools/ladder-probe.d.ts +65 -0
  148. package/dist/tools/ladder-probe.d.ts.map +1 -0
  149. package/dist/tools/ladder-probe.js +445 -0
  150. package/dist/tools/ladder-probe.js.map +1 -0
  151. package/dist/tools/ladder.d.ts.map +1 -1
  152. package/dist/tools/ladder.js +5 -5
  153. package/dist/tools/ladder.js.map +1 -1
  154. package/dist/tools/leak-report.d.ts +23 -0
  155. package/dist/tools/leak-report.d.ts.map +1 -1
  156. package/dist/tools/leak-report.js +153 -11
  157. package/dist/tools/leak-report.js.map +1 -1
  158. package/dist/tools/load-snapshot.d.ts +11 -0
  159. package/dist/tools/load-snapshot.d.ts.map +1 -1
  160. package/dist/tools/load-snapshot.js +83 -10
  161. package/dist/tools/load-snapshot.js.map +1 -1
  162. package/dist/tools/metric.d.ts +13 -0
  163. package/dist/tools/metric.d.ts.map +1 -0
  164. package/dist/tools/metric.js +185 -0
  165. package/dist/tools/metric.js.map +1 -0
  166. package/dist/tools/module-attribution.d.ts +36 -0
  167. package/dist/tools/module-attribution.d.ts.map +1 -0
  168. package/dist/tools/module-attribution.js +235 -0
  169. package/dist/tools/module-attribution.js.map +1 -0
  170. package/dist/tools/next-measurement.d.ts +26 -0
  171. package/dist/tools/next-measurement.d.ts.map +1 -0
  172. package/dist/tools/next-measurement.js +149 -0
  173. package/dist/tools/next-measurement.js.map +1 -0
  174. package/dist/tools/object-cost-breakdown.d.ts.map +1 -1
  175. package/dist/tools/object-cost-breakdown.js +64 -0
  176. package/dist/tools/object-cost-breakdown.js.map +1 -1
  177. package/dist/tools/population-vs-owners.d.ts +12 -0
  178. package/dist/tools/population-vs-owners.d.ts.map +1 -0
  179. package/dist/tools/population-vs-owners.js +183 -0
  180. package/dist/tools/population-vs-owners.js.map +1 -0
  181. package/dist/tools/property-names.d.ts +29 -0
  182. package/dist/tools/property-names.d.ts.map +1 -0
  183. package/dist/tools/property-names.js +131 -0
  184. package/dist/tools/property-names.js.map +1 -0
  185. package/dist/tools/quick-diagnosis.d.ts.map +1 -1
  186. package/dist/tools/quick-diagnosis.js +7 -1
  187. package/dist/tools/quick-diagnosis.js.map +1 -1
  188. package/dist/tools/react-owners.d.ts +12 -0
  189. package/dist/tools/react-owners.d.ts.map +1 -0
  190. package/dist/tools/react-owners.js +167 -0
  191. package/dist/tools/react-owners.js.map +1 -0
  192. package/dist/tools/react-update-queues.d.ts +12 -0
  193. package/dist/tools/react-update-queues.d.ts.map +1 -0
  194. package/dist/tools/react-update-queues.js +173 -0
  195. package/dist/tools/react-update-queues.js.map +1 -0
  196. package/dist/tools/replicate.d.ts +49 -0
  197. package/dist/tools/replicate.d.ts.map +1 -0
  198. package/dist/tools/replicate.js +241 -0
  199. package/dist/tools/replicate.js.map +1 -0
  200. package/dist/tools/retainer-diff.d.ts +12 -0
  201. package/dist/tools/retainer-diff.d.ts.map +1 -0
  202. package/dist/tools/retainer-diff.js +168 -0
  203. package/dist/tools/retainer-diff.js.map +1 -0
  204. package/dist/tools/retainer-summary.d.ts +17 -0
  205. package/dist/tools/retainer-summary.d.ts.map +1 -1
  206. package/dist/tools/retainer-summary.js +85 -22
  207. package/dist/tools/retainer-summary.js.map +1 -1
  208. package/dist/tools/retainer-trace.d.ts.map +1 -1
  209. package/dist/tools/retainer-trace.js +11 -1
  210. package/dist/tools/retainer-trace.js.map +1 -1
  211. package/dist/tools/round-audit.d.ts +66 -0
  212. package/dist/tools/round-audit.d.ts.map +1 -0
  213. package/dist/tools/round-audit.js +258 -0
  214. package/dist/tools/round-audit.js.map +1 -0
  215. package/dist/tools/round-digest.d.ts +12 -0
  216. package/dist/tools/round-digest.d.ts.map +1 -0
  217. package/dist/tools/round-digest.js +190 -0
  218. package/dist/tools/round-digest.js.map +1 -0
  219. package/dist/tools/script-census.d.ts +12 -0
  220. package/dist/tools/script-census.d.ts.map +1 -0
  221. package/dist/tools/script-census.js +182 -0
  222. package/dist/tools/script-census.js.map +1 -0
  223. package/dist/tools/sequence-analysis.d.ts +4 -0
  224. package/dist/tools/sequence-analysis.d.ts.map +1 -1
  225. package/dist/tools/sequence-analysis.js +53 -9
  226. package/dist/tools/sequence-analysis.js.map +1 -1
  227. package/dist/tools/server-status.d.ts.map +1 -1
  228. package/dist/tools/server-status.js +4 -1
  229. package/dist/tools/server-status.js.map +1 -1
  230. package/dist/tools/settle-check.d.ts +12 -0
  231. package/dist/tools/settle-check.d.ts.map +1 -0
  232. package/dist/tools/settle-check.js +188 -0
  233. package/dist/tools/settle-check.js.map +1 -0
  234. package/dist/tools/sliced-strings.d.ts.map +1 -1
  235. package/dist/tools/sliced-strings.js +2 -1
  236. package/dist/tools/sliced-strings.js.map +1 -1
  237. package/dist/tools/snapshots.d.ts.map +1 -1
  238. package/dist/tools/snapshots.js +24 -6
  239. package/dist/tools/snapshots.js.map +1 -1
  240. package/dist/tools/sparse-elements.d.ts +12 -0
  241. package/dist/tools/sparse-elements.d.ts.map +1 -0
  242. package/dist/tools/sparse-elements.js +215 -0
  243. package/dist/tools/sparse-elements.js.map +1 -0
  244. package/dist/tools/stale-collections.d.ts +17 -0
  245. package/dist/tools/stale-collections.d.ts.map +1 -1
  246. package/dist/tools/stale-collections.js +28 -0
  247. package/dist/tools/stale-collections.js.map +1 -1
  248. package/dist/tools/string-patterns.d.ts.map +1 -1
  249. package/dist/tools/string-patterns.js +2 -1
  250. package/dist/tools/string-patterns.js.map +1 -1
  251. package/dist/tools/tools-index.d.ts.map +1 -1
  252. package/dist/tools/tools-index.js +155 -5
  253. package/dist/tools/tools-index.js.map +1 -1
  254. package/dist/tools/trace-all.d.ts +42 -0
  255. package/dist/tools/trace-all.d.ts.map +1 -0
  256. package/dist/tools/trace-all.js +263 -0
  257. package/dist/tools/trace-all.js.map +1 -0
  258. package/dist/tools/unit-cost.d.ts +12 -0
  259. package/dist/tools/unit-cost.d.ts.map +1 -0
  260. package/dist/tools/unit-cost.js +219 -0
  261. package/dist/tools/unit-cost.js.map +1 -0
  262. package/dist/tools/verify-fix.d.ts.map +1 -1
  263. package/dist/tools/verify-fix.js +11 -3
  264. package/dist/tools/verify-fix.js.map +1 -1
  265. package/dist/tools/weakref-census.d.ts +15 -0
  266. package/dist/tools/weakref-census.d.ts.map +1 -0
  267. package/dist/tools/weakref-census.js +147 -0
  268. package/dist/tools/weakref-census.js.map +1 -0
  269. package/dist/tools/what-if.d.ts +12 -0
  270. package/dist/tools/what-if.d.ts.map +1 -0
  271. package/dist/tools/what-if.js +285 -0
  272. package/dist/tools/what-if.js.map +1 -0
  273. package/dist/utils.d.ts +92 -2
  274. package/dist/utils.d.ts.map +1 -1
  275. package/dist/utils.js +172 -5
  276. package/dist/utils.js.map +1 -1
  277. package/package.json +3 -3
@@ -8,18 +8,156 @@
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
16
  const { utils, NumericSet } = memlabCore;
17
+ import { nearestFiber, fiberComponentName } from '../react-shapes.js';
18
+ import { readElements as readElementsInfo } from '../heap-shapes.js';
14
19
  import { getCurrentHandle, getSavedResult, getSnapshot, isLightSnapshot, listSavedResults, setSavedResult, getEvalScratch, getSnapshotMetadata, } from '../heap-state.js';
15
- import { errorResult, toolResult, serializeNodeSummary, serializeNodeDetail, formatBytes, formatNumber, markdownTable, isNodeWorthInspecting, makeNamePatternTest, filterLargestObjects, queryNodes, enumerateMapEntries, enumerateSetElements, objectContentSignature, boundedDominatorRetainedSize, } from '../utils.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
- // Prefix for user-named result sets inside the per-snapshot eval scratch, so
18
- // they cannot collide with the internal `__classTypeIndex` / `__withProp:` keys.
19
24
  function truncate(str, max) {
20
25
  if (str.length <= max)
21
26
  return str;
22
- 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
+ };
23
161
  }
24
162
  class BudgetExceeded extends Error {
25
163
  constructor(max) {
@@ -45,9 +183,6 @@ export function unwrapNode(node) {
45
183
  }
46
184
  return node;
47
185
  }
48
- function unwrapNodes(nodes) {
49
- return Array.isArray(nodes) ? nodes.map(unwrapNode) : nodes;
50
- }
51
186
  /**
52
187
  * `node.retainedSize` read off a node inside eval has been observed to come
53
188
  * back ~0 for every node on some loads, while the same id read through
@@ -126,6 +261,11 @@ function wrapEdgeIterable(iterable) {
126
261
  function wrapSnapshot(snapshot, budget) {
127
262
  return new Proxy(snapshot, {
128
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;
129
269
  if (prop === 'getNodeById') {
130
270
  const orig = target.getNodeById.bind(target);
131
271
  return (id) => wrapNode(orig(id));
@@ -137,7 +277,25 @@ function wrapSnapshot(snapshot, budget) {
137
277
  if (nodesProp === 'forEach') {
138
278
  const origForEach = nodesTarget.forEach.bind(nodesTarget);
139
279
  return (cb) => {
280
+ const stride = budget.sampleEvery ?? 1;
281
+ const only = budget.restrictTo;
140
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;
141
299
  if (++budget.visited > budget.max) {
142
300
  budget.exceeded = true;
143
301
  throw new BudgetExceeded(budget.max);
@@ -173,6 +331,244 @@ const RETENTION_IDENTIFIERS = [
173
331
  'pathEdge',
174
332
  'filterLargestObjects',
175
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
+ }
176
572
  export function registerEval(server) {
177
573
  server.tool('memlab_eval', 'Execute arbitrary JavaScript code against the loaded heap snapshot. ' +
178
574
  'The code runs in a sandboxed VM with access to `snapshot` (IHeapSnapshot), ' +
@@ -205,10 +601,10 @@ export function registerEval(server) {
205
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. ' +
206
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`.', {
207
603
  mode: z
208
- .enum(['eval', 'describe_env', 'list_saved'])
604
+ .enum(['eval', 'describe_env', 'list_saved', 'lint'])
209
605
  .optional()
210
606
  .default('eval')
211
- .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. "list_saved" ignores `code` and lists the named result sets saved so far for this snapshot.'),
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.'),
212
608
  code: z
213
609
  .string()
214
610
  .optional()
@@ -222,24 +618,36 @@ export function registerEval(server) {
222
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)), ' +
223
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), ' +
224
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), ' +
225
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), ' +
226
624
  'aggregateRetained(ids[])->{retained,exact} (dominator-deduped retained for a SET of ids, no double-counting), ' +
227
- 'iterByClass(name, {type?})->nodes[] & iterByType(type)->nodes[] (INDEXED iteration — no full scan), ' +
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), ' +
228
626
  'classCounts({pattern?, type?, minCount?})->[{name,type,count,selfSize}] (one-pass histogram, cached), ' +
229
627
  'entries(nodeOrId)->[{key,value}] (generic Map/Set/WeakMap/Array/object walk, holes filtered), ' +
230
- 'edgeTarget(nodeOrId, edgeName)->node|null, isRealDetached(node)->boolean (same filtering the tools apply internally), ' +
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), ' +
231
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}], ' +
232
634
  'pathBetween(fromId, toId, {maxNodes?})->{found,exhausted,path[]}, ' +
233
635
  'save(name, value) / load(name, {allowCrossSnapshot?}) / listSaved() (SESSION-scoped, survives loading another snapshot) }), ' +
234
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. ' +
235
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. ' +
236
639
  'Node traversal: use node.references (outgoing) and node.referrers (incoming) with for-of. ' +
237
640
  'Edge properties: .name_or_index, .type, .toNode, .fromNode.'),
238
641
  timeout_ms: z
239
642
  .number()
240
643
  .optional()
241
- .default(60000)
242
- .describe('Execution timeout in milliseconds (default 60000). Full-snapshot scans on large heaps may need 120000+.'),
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.`),
243
651
  save_as: z
244
652
  .string()
245
653
  .regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
@@ -250,770 +658,1712 @@ export function registerEval(server) {
250
658
  .optional()
251
659
  .default(false)
252
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.'),
253
676
  max_nodes: z
254
677
  .number()
255
678
  .int()
256
679
  .min(1)
257
680
  .optional()
258
- .default(20000000)
259
- .describe('Abort a `snapshot.nodes.forEach` walk after this many node visits (default 20000000, i.e. effectively unlimited). On abort the partial `result` is returned with a note instead of failing, so a broad exploratory scan can be attempted safely. Reported back as `nodes_visited` on every call.'),
260
- }, async ({ mode, code, timeout_ms, save_as, max_nodes, dry_run }) => {
261
- const budget = { visited: 0, max: max_nodes, exceeded: false };
262
- try {
263
- if (mode === 'describe_env') {
264
- return toolResult(describeEnv());
265
- }
266
- if (mode === 'list_saved') {
267
- return toolResult(describeSaved());
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.)'));
268
795
  }
269
- if (code == null || code.trim() === '') {
270
- return errorResult(new Error('No code provided. Pass `code`, or use mode:"describe_env" to see the available globals and conventions.'));
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
+ }
271
855
  }
272
- // Light snapshots are allowed here. Most eval code touches only
273
- // `name`, `type`, `self_size`, `references` and `referrers`, none of
274
- // which the dominator pass produces — refusing the whole tool forced a
275
- // full (2x slower) load for counts-only work on a baseline rung. What
276
- // IS unavailable is refused precisely instead: by a pre-flight text
277
- // check below, and by the helpers themselves as a backstop.
278
- const light = isLightSnapshot();
279
- const snapshot = getSnapshot({ allowLight: true });
280
- const currentHandle = getCurrentHandle() ?? '(none)';
281
- if (light) {
282
- const needsRetention = RETENTION_IDENTIFIERS.filter(id => new RegExp(`\\b${id}\\b`).test(code));
283
- if (needsRetention.length > 0) {
284
- 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(', ')}. ` +
285
- 'Those would read 0 / undefined rather than fail, so the run is refused instead of returning confident zeros. ' +
286
- '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. ' +
287
- '(If the identifier only appears inside a string literal, this is a false match; the same call succeeds on a non-light load.)'));
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;
882
+ }
288
883
  }
884
+ if (!found)
885
+ missing++;
289
886
  }
290
- if (dry_run) {
291
- // Estimate, do not execute. A full-heap walk is detected textually —
292
- // the honest limit of a pre-flight check, and stated as such rather
293
- // than implying the code was analysed.
294
- const meta = getSnapshotMetadata();
295
- const fullWalk = /\b(?:snapshot\.)?(?:nodes|edges)\s*\.\s*forEach/.test(code);
296
- const indexed = /helpers\.(byClass|byTypename|withProp|getNode)/.test(code);
297
- return toolResult([
298
- '## Dry run — nothing was executed',
299
- '',
300
- `Snapshot: ${formatNumber(meta?.nodeCount ?? 0)} nodes, ${formatNumber(meta?.edgeCount ?? 0)} edges.`,
301
- `Walk budget (\`max_nodes\`): ${formatNumber(budget.max)}.`,
302
- '',
303
- fullWalk
304
- ? `⚠ 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` are indexed and avoid the walk when you know what you are looking for.'}`
305
- : indexed
306
- ? 'No full-heap walk detected; the code uses the indexed helpers, which do not scan the heap.'
307
- : '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.',
308
- '',
309
- '_Re-run without `dry_run` to execute._',
310
- ].join('\n'));
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;
311
897
  }
312
- const consoleOutput = [];
313
- const capturedConsole = {
314
- log: (...args) => consoleOutput.push(args.map(String).join(' ')),
315
- warn: (...args) => consoleOutput.push('[warn] ' + args.map(String).join(' ')),
316
- error: (...args) => consoleOutput.push('[error] ' + args.map(String).join(' ')),
317
- info: (...args) => consoleOutput.push('[info] ' + args.map(String).join(' ')),
318
- };
319
- const groupReferrersByEdge = (nodeId) => {
320
- const target = snapshot.getNodeById(nodeId);
321
- if (!target)
322
- return {};
323
- const groups = {};
324
- for (const edge of target.referrers) {
325
- const eName = String(edge.name_or_index);
326
- const from = edge.fromNode;
327
- if (!groups[eName])
328
- groups[eName] = [];
329
- if (groups[eName].length < 10) {
330
- groups[eName].push({
331
- fromName: from.name,
332
- fromType: from.type,
333
- fromId: from.id,
334
- });
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;
335
945
  }
336
946
  }
337
- return groups;
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,
338
971
  };
339
- const groupArrayElementsByProperty = (arrayNodeId, propertyName) => {
340
- const arrNode = snapshot.getNodeById(arrayNodeId);
341
- if (!arrNode)
342
- return { error: 'Node not found' };
343
- const groups = {};
344
- let missing = 0;
345
- let total = 0;
346
- for (const edge of arrNode.references) {
347
- if (edge.type !== 'element')
348
- continue;
349
- const elem = edge.toNode;
350
- if (elem.id <= 3)
351
- continue;
352
- total++;
353
- let found = false;
354
- for (const propEdge of elem.references) {
355
- if (String(propEdge.name_or_index) === propertyName) {
356
- const target = propEdge.toNode;
357
- const key = target.name;
358
- if (!groups[key])
359
- groups[key] = { count: 0, exampleId: target.id };
360
- groups[key].count++;
361
- found = true;
362
- break;
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))
1126
+ 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);
363
1180
  }
1181
+ break;
364
1182
  }
365
- if (!found)
366
- missing++;
367
- }
368
- return { groups, total, missing };
369
- };
370
- const isOrphaned = (nodeId, ownershipEdgeNames) => {
371
- const target = snapshot.getNodeById(nodeId);
372
- if (!target)
373
- return false;
374
- const ownerSet = new Set(ownershipEdgeNames);
375
- for (const edge of target.referrers) {
376
- if (ownerSet.has(String(edge.name_or_index)))
377
- return false;
378
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)
379
1206
  return true;
380
- };
381
- // Authoritative retained sizes (Feedback round 3 §3b). Reading
382
- // `.retainedSize` off proxied/iterated nodes inside eval can come back
383
- // ~0; these helpers look the node up fresh on the real snapshot (the
384
- // same path the dedicated tools use) so custom analyses can rank by
385
- // retained size.
386
- // On a light snapshot these would return 0 for every id, which is
387
- // indistinguishable from a genuinely tiny object. Throw instead: the
388
- // pre-flight check above catches the common case, and this covers code
389
- // that reaches them indirectly.
390
- const requireRetention = (what) => {
391
- if (!light)
392
- return;
393
- 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}).`);
394
- };
395
- const retainedSize = (id) => {
396
- requireRetention('retainedSize');
397
- const n = snapshot.getNodeById(id);
398
- return n ? n.retainedSize : 0;
399
- };
400
- const retainedSizes = (ids) => {
401
- requireRetention('retainedSizes');
402
- const out = {};
403
- for (const id of ids) {
404
- const n = snapshot.getNodeById(id);
405
- 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
+ }
406
1217
  }
407
- return out;
408
- };
409
- const countUniqueTargets = (arrayNodeId, propertyName) => {
410
- const arrNode = snapshot.getNodeById(arrayNodeId);
411
- if (!arrNode)
412
- return { error: 'Node not found' };
413
- const uniqueIds = new Set();
414
- let total = 0;
415
- for (const edge of arrNode.references) {
416
- if (edge.type !== 'element')
417
- continue;
418
- const elem = edge.toNode;
419
- if (elem.id <= 3)
420
- continue;
421
- total++;
422
- for (const propEdge of elem.references) {
423
- if (String(propEdge.name_or_index) === propertyName) {
424
- uniqueIds.add(propEdge.toNode.id);
425
- break;
426
- }
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;
427
1252
  }
428
1253
  }
429
- return { uniqueCount: uniqueIds.size, totalElements: total };
430
- };
431
- // Compact, ready-to-use view of a node (no proxy, values inlined) so
432
- // custom scripts get data they can JSON-return directly instead of
433
- // re-deriving `.toStringNode()?.stringValue` etc. `retained_size` is
434
- // resolved via the trusted `retainedSize(id)` re-lookup (NOT the raw
435
- // `.retainedSize`, which can read back ~0 inside eval on some loads — the
436
- // foot-gun this whole tool's description warns about).
437
- const nodeBrief = (n) => n == null
438
- ? null
439
- : {
440
- id: n.id,
441
- name: n.name,
442
- type: n.type,
443
- self_size: n.self_size,
444
- retained_size: retainedSize(n.id),
445
- string: n.isString
446
- ? (n.toStringNode()?.stringValue ?? null)
447
- : null,
448
- };
449
- const resolveNode = (nodeOrId) => {
450
- if (nodeOrId == null)
451
- return null;
452
- const id = typeof nodeOrId === 'number' ? nodeOrId : nodeOrId.id;
453
- return snapshot.getNodeById(id);
454
- };
455
- // Correctly enumerate Map/WeakMap entries and Set elements via the shared
456
- // index-aware backing-store walk (handles browser `internal`-typed slots
457
- // AND SMI-value gaps). Removes the #1 eval foot-gun: hand-rolling this
458
- // and silently getting 0 results by filtering on `type === 'element'`.
459
- const mapEntries = (id, limit = 1000) => {
460
- const node = snapshot.getNodeById(id);
461
- if (!node)
462
- throw new Error(`mapEntries: node @${id} not found`);
463
- // Guard the node type enumerateMapEntries assumes key/value slots, so
464
- // running it on a Set (element/chain layout) would emit each element as
465
- // a lone key with value:null, which is silently misleading.
466
- if (node.name !== 'Map' && node.name !== 'WeakMap') {
467
- throw new Error(`mapEntries: @${id} is a ${node.name} (${node.type}), not a Map/WeakMap. ` +
468
- `For a Set use helpers.setElements(${id}); otherwise inspect with helpers.props()/get_references.`);
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;
469
1299
  }
470
- return enumerateMapEntries(node)
471
- .slice(0, limit)
472
- .map(e => ({ key: nodeBrief(e.key), value: nodeBrief(e.value) }));
473
- };
474
- const setElements = (id, limit = 1000) => {
475
- const node = snapshot.getNodeById(id);
476
- if (!node)
477
- throw new Error(`setElements: node @${id} not found`);
478
- if (node.name !== 'Set' && node.name !== 'WeakSet') {
479
- throw new Error(`setElements: @${id} is a ${node.name} (${node.type}), not a Set/WeakSet. ` +
480
- `For a Map use helpers.mapEntries(${id}).`);
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++;
481
1312
  }
482
- return enumerateSetElements(node).slice(0, limit).map(nodeBrief);
483
- };
484
- // Read an object's own properties as a plain object: scalars inlined,
485
- // object-valued props as `{ref, name, type}`. Saves the repetitive
486
- // `for (const e of n.references) if (e.name_or_index === X)` boilerplate.
487
- const props = (nodeOrId) => {
488
- const node = resolveNode(nodeOrId);
489
- if (!node)
490
- return {};
491
- const out = {};
492
- for (const e of node.references) {
493
- if (e.type !== 'property')
494
- continue;
495
- const name = String(e.name_or_index);
496
- if (name === '__proto__')
497
- continue;
498
- const t = e.toNode;
499
- if (t.isString)
500
- out[name] = t.toStringNode()?.stringValue ?? '';
501
- else if (t.name === 'true')
502
- out[name] = true;
503
- else if (t.name === 'false')
504
- out[name] = false;
505
- else if (t.name === 'null')
506
- out[name] = null;
507
- else if (t.name === 'undefined')
508
- out[name] = undefined;
509
- else
510
- out[name] = { ref: t.id, name: t.name, type: t.type };
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);
511
1355
  }
512
- return out;
513
- };
514
- const getProp = (nodeOrId, name) => props(nodeOrId)[name];
515
- const shapeSignature = (nodeOrId, opts) => {
516
- const node = resolveNode(nodeOrId);
517
- return node ? objectContentSignature(node, opts ?? {}) : '';
518
- };
519
- // Index helpers build once per snapshot, memoized in the eval scratch
520
- // so a follow-up call is index-speed, not a fresh 12M-node scan. Ids are
521
- // only valid for the active snapshot (the scratch is keyed to it).
522
- const scratch = getEvalScratch();
523
- const buildClassTypeIndex = () => {
524
- const cached = scratch.__classTypeIndex;
525
- if (cached)
526
- return cached;
527
- const byClass = new Map();
528
- const byTypename = new Map();
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();
529
1371
  snapshot.nodes.forEach((node) => {
530
- if (node.id <= 3)
531
- return; // skip oddball/root nodes, matching the histogram/duplicate-objects tools for count parity
532
- // Index EVERY node type. Restricting this to `object` made the
533
- // helper silently return [] for closures, strings, arrays and
534
- // native (`blink::*`) nodes — which is most of what other tools
535
- // report. Measured: byClass('setComposerLinks_$0') returned [] on a
536
- // snapshot where a manual walk found 1,011 of them, because the
537
- // class is a closure; the empty result reads as "does not exist".
538
- // memlab_find_nodes_by_class matches any type by default and this
539
- // helper is documented as its indexed equivalent, so the two must
540
- // agree.
541
- let a = byClass.get(node.name);
542
- if (!a) {
543
- a = [];
544
- byClass.set(node.name, a);
545
- }
546
- a.push(node.id);
547
- // `__typename` is a JS object property, so only object nodes can
548
- // carry one; skipping the edge walk for other types keeps the
549
- // widened index roughly as cheap as the object-only one.
550
- if (node.type !== 'object')
1372
+ if (node.id <= 3 || node.type !== 'closure')
551
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++;
552
1380
  for (const e of node.references) {
553
- if (e.type === 'property' &&
554
- String(e.name_or_index) === '__typename') {
555
- const t = e.toNode;
556
- const tn = t.isString ? t.toStringNode()?.stringValue : null;
557
- if (tn) {
558
- let b = byTypename.get(tn);
559
- if (!b) {
560
- b = [];
561
- byTypename.set(tn, b);
562
- }
563
- b.push(node.id);
564
- }
1381
+ if (String(e.name_or_index) === 'context' &&
1382
+ e.type === 'internal') {
1383
+ rec.withScope++;
565
1384
  break;
566
1385
  }
567
1386
  }
568
1387
  });
569
- const idx = { byClass, byTypename };
570
- scratch.__classTypeIndex = idx;
571
- return idx;
572
- };
573
- const byClass = (name, opts) => {
574
- const ids = buildClassTypeIndex().byClass.get(name) ?? [];
575
- const want = opts?.type;
576
- if (want == null)
577
- return ids;
578
- return ids.filter(id => snapshot.getNodeById(id)?.type === want);
579
- };
580
- const byTypename = (name) => buildClassTypeIndex().byTypename.get(name) ?? [];
581
- const withProp = (name) => {
582
- const key = `__withProp:${name}`;
583
- const cached = scratch[key];
584
- if (cached)
585
- return cached;
586
- const ids = [];
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 = [];
587
1412
  snapshot.nodes.forEach((node) => {
588
- if (node.id <= 3)
589
- return; // skip oddball/root nodes for parity with other tools
590
- // Every node type is scanned: the `property` edge check below is
591
- // what constrains the match, and closures do carry named property
592
- // edges. Restricting the walk to `object` hid them, the same way it
593
- // hid non-object classes from byClass.
1413
+ if (node.id <= 3 || node.type !== 'object')
1414
+ return;
1415
+ let cb = null;
1416
+ let ctx = null;
594
1417
  for (const e of node.references) {
595
- if (e.type === 'property' && String(e.name_or_index) === name) {
596
- ids.push(node.id);
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)
597
1426
  break;
598
- }
1427
+ }
1428
+ if (cb != null && ctx != null) {
1429
+ found.push({ id: node.id, callback: cb.name, context: ctx.name });
599
1430
  }
600
1431
  });
601
- scratch[key] = ids;
602
- return ids;
603
- };
604
- // Dominator-deduped retained size for a SET of ids (bounded walk). Unlike
605
- // summing helpers.retainedSize over the ids, this does not double-count
606
- // bytes when one id dominates another in the set.
607
- const aggregateRetained = (ids) => {
608
- requireRetention('aggregateRetained');
609
- return boundedDominatorRetainedSize(new NumericSet(ids), snapshot);
610
- };
611
- // ---- additional traversal helpers -------------------------------
612
- // Each of these was hand-written inside `code` during a leak hunt,
613
- // several of them more than once and with small differences that made
614
- // results incomparable. Shipping them makes the common traversals both
615
- // cheaper to write and consistent with what the dedicated tools do.
616
- // The oddball/root filtering the tools apply internally. Hand-written
617
- // eval that omits it counts nodes the tools do not, so the two disagree
618
- // for reasons that have nothing to do with the question being asked.
619
- const isRealDetached = (node) => {
620
- const n = unwrapNode(node);
621
- if (n == null || n.id <= 3)
622
- return false;
623
- return n.is_detached || n.name.startsWith('Detached ');
624
- };
625
- // Cached type -> ids index, mirroring the class index above, so a
626
- // second pass over "every closure" does not re-walk the heap.
627
- const buildTypeIndex = () => {
628
- const cached = scratch.__typeIndex;
629
- if (cached)
630
- return cached;
631
- const byType = new Map();
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();
632
1513
  snapshot.nodes.forEach((node) => {
633
1514
  if (node.id <= 3)
634
1515
  return;
635
- let a = byType.get(node.type);
636
- if (!a) {
637
- a = [];
638
- byType.set(node.type, a);
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
+ });
639
1529
  }
640
- a.push(node.id);
641
1530
  });
642
- scratch.__typeIndex = byType;
643
- return byType;
644
- };
645
- const nodesFromIds = (ids) => {
646
- const out = [];
647
- for (const id of ids) {
648
- const n = snapshot.getNodeById(id);
649
- if (n)
650
- out.push(n);
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')
1561
+ continue;
1562
+ if (String(e.name_or_index) === part) {
1563
+ next = e.toNode;
1564
+ break;
1565
+ }
651
1566
  }
652
- return out;
653
- };
654
- const iterByClass = (name, opts) => nodesFromIds(byClass(name, opts)).map(wrapNode);
655
- const iterByType = (type) => nodesFromIds(buildTypeIndex().get(type) ?? []).map(wrapNode);
656
- // One-pass class histogram, cached, optionally filtered. `byClass`
657
- // answers "where are the X"; this answers "what is in here at all",
658
- // which otherwise means a full manual walk every time.
659
- const classCounts = (opts) => {
660
- const cacheKey = '__classCounts';
661
- let all = scratch[cacheKey];
662
- if (!all) {
663
- const acc = new Map();
664
- snapshot.nodes.forEach((node) => {
665
- if (node.id <= 3)
666
- return;
667
- const key = `${node.type}::${node.name}`;
668
- const e = acc.get(key);
669
- if (e) {
670
- e.count++;
671
- e.selfSize += node.self_size;
672
- }
673
- else {
674
- acc.set(key, {
675
- name: node.name,
676
- type: node.type,
677
- count: 1,
678
- selfSize: node.self_size,
679
- });
680
- }
681
- });
682
- all = [...acc.values()].sort((a, b) => b.count - a.count);
683
- scratch[cacheKey] = all;
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
+ };
684
1578
  }
685
- const matches = makeNamePatternTest(opts?.pattern);
686
- const minCount = opts?.minCount ?? 1;
687
- return all.filter(r => r.count >= minCount &&
688
- (opts?.type == null || r.type === opts.type) &&
689
- matches(r.name));
690
- };
691
- // The node behind a named edge. Written from scratch in four separate
692
- // evals because `props()` returns {ref,name,type} wrappers, which are
693
- // awkward exactly when the node itself is what you need.
694
- const edgeTarget = (nodeOrId, edgeName) => {
695
- const n = typeof nodeOrId === 'number'
696
- ? snapshot.getNodeById(nodeOrId)
697
- : unwrapNode(nodeOrId);
698
- if (n == null)
699
- return null;
700
- for (const e of n.references) {
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) {
701
1603
  if (e.type === 'hidden')
702
1604
  continue;
703
- if (String(e.name_or_index) !== edgeName)
704
- continue;
705
- return e.toNode.id > 3 ? wrapNode(e.toNode) : null;
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)
1610
+ break;
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 });
1615
+ }
706
1616
  }
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)
707
1628
  return null;
708
- };
709
- // Generic container walk. `mapEntries` / `setElements` cover Map and
710
- // Set; WeakMap tables and plain arrays needed a manual `references`
711
- // walk with hole filtering every time.
712
- const entries = (nodeOrId) => {
713
- const n = typeof nodeOrId === 'number'
714
- ? snapshot.getNodeById(nodeOrId)
715
- : unwrapNode(nodeOrId);
716
- if (n == null)
717
- return [];
718
- if (n.name === 'Map' || n.name === 'WeakMap') {
719
- return enumerateMapEntries(n).map(e => ({
720
- key: wrapNode(e.key),
721
- value: e.value == null ? null : wrapNode(e.value),
722
- }));
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;
723
1663
  }
724
- if (n.name === 'Set' || n.name === 'WeakSet') {
725
- return enumerateSetElements(n).map(el => ({
726
- key: null,
727
- value: wrapNode(el),
728
- }));
1664
+ if (seen.size >= maxHops) {
1665
+ terminated = 'cap';
1666
+ break;
729
1667
  }
730
- const out = [];
731
- for (const e of n.references) {
732
- if (e.type === 'hidden')
733
- continue;
734
- const name = String(e.name_or_index);
735
- if (name === '__proto__' || name === 'map')
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)
736
1674
  continue;
737
- if (e.type === 'element') {
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 } : {}),
1685
+ };
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;
738
1731
  out.push({
739
- key: Number(e.name_or_index),
740
- value: wrapNode(e.toNode),
1732
+ key: Number(el.name_or_index),
1733
+ value: wrapNode(el.toNode),
741
1734
  });
742
1735
  }
743
- else if (name === 'elements' && e.type === 'internal') {
744
- for (const el of e.toNode.references) {
745
- if (el.type !== 'element')
746
- continue;
747
- out.push({
748
- key: Number(el.name_or_index),
749
- value: wrapNode(el.toNode),
750
- });
751
- }
752
- }
753
- else if (e.type === 'property') {
754
- out.push({ key: name, value: wrapNode(e.toNode) });
755
- }
756
1736
  }
757
- return out;
758
- };
759
- // What does this node actually own? The question behind
760
- // memlab_dominator_attribution, exposed for ad-hoc populations.
761
- const dominates = (id, opts) => {
762
- requireRetention('dominates');
763
- const limit = opts?.limit ?? 1000;
764
- const pop = opts?.population;
765
- let count = 0;
766
- let selfSize = 0;
767
- const ids = [];
768
- let truncated = false;
769
- snapshot.nodes.forEach((node) => {
770
- if (node.id <= 3 || node.id === id)
771
- return;
772
- if (pop != null && !pop(wrapNode(node)))
773
- return;
774
- let cur = node.dominatorNode ?? null;
775
- let hops = 0;
776
- while (cur && hops++ < 500) {
777
- if (cur.id === id) {
778
- count++;
779
- selfSize += node.self_size;
780
- if (ids.length < limit)
781
- ids.push(node.id);
782
- else
783
- truncated = true;
784
- break;
785
- }
786
- const next = cur.dominatorNode ?? null;
787
- if (!next || next.id === cur.id)
788
- break;
789
- cur = next;
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;
790
1769
  }
791
- });
792
- return { count, selfSize, ids, truncated };
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,
793
1850
  };
794
- // Shortest reference path a -> b, by BFS over outgoing edges. Bounded,
795
- // and reports that it gave up rather than returning null as if no path
796
- // existed.
797
- const pathBetween = (fromId, toId, opts) => {
798
- const maxNodes = opts?.maxNodes ?? 200_000;
799
- const start = snapshot.getNodeById(fromId);
800
- if (start == null || snapshot.getNodeById(toId) == null) {
801
- return { found: false, exhausted: false, path: [] };
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;
802
1882
  }
803
- const prev = new Map();
804
- const seen = new Set([fromId]);
805
- let queue = [start];
806
- let visited = 0;
807
- while (queue.length > 0) {
808
- const next = [];
809
- for (const node of queue) {
810
- if (++visited > maxNodes) {
811
- return { found: false, exhausted: true, path: [] };
812
- }
813
- for (const e of node.references) {
814
- const t = e.toNode;
815
- if (t.id <= 3 || seen.has(t.id))
816
- continue;
817
- seen.add(t.id);
818
- prev.set(t.id, { via: String(e.name_or_index), from: node.id });
819
- if (t.id === toId) {
820
- const path = [];
821
- let cur = toId;
822
- while (cur !== fromId) {
823
- const p = prev.get(cur);
824
- if (p == null)
825
- break;
826
- const n = snapshot.getNodeById(cur);
827
- path.unshift(`.${p.via} -> @${cur} ${n?.name ?? ''}`);
828
- cur = p.from;
829
- }
830
- path.unshift(`@${fromId} ${start.name}`);
831
- return { found: true, exhausted: false, path };
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
+ };
1921
+ }
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;
832
1948
  }
833
- next.push(t);
1949
+ path.unshift(`@${fromId} ${start.name}`);
1950
+ return { found: true, exhausted: false, path };
834
1951
  }
1952
+ next.push(t);
835
1953
  }
836
- queue = next;
837
1954
  }
838
- return { found: false, exhausted: false, path: [] };
839
- };
840
- // Named result sets are SESSION-scoped, not snapshot-scoped: comparing
841
- // a baseline scan against a final scan is the whole job, and the old
842
- // per-snapshot scratch dropped the baseline the moment the next rung
843
- // was loaded exactly when it was needed.
1955
+ queue = next;
1956
+ }
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).');
1978
+ }
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.');
1982
+ }
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.
844
2047
  //
845
- // Node ids, however, are per-capture. A set of ids saved against one
846
- // snapshot means nothing against another, so a cross-snapshot load is
847
- // refused unless the caller opts in. Counts and strings are portable;
848
- // ids are not, and silently letting them through is the failure this
849
- // whole class of guard exists to prevent.
850
- const save = (name, value) => {
851
- setSavedResult(name, value, currentHandle);
852
- return value;
853
- };
854
- const load = (name, opts) => {
855
- const entry = getSavedResult(name);
856
- if (entry == null) {
857
- throw new Error(`No saved result named "${name}". Saved names: ${savedNames().join(', ') || '(none)'}. ` +
858
- 'Save one with the save_as parameter or helpers.save(name, value).');
859
- }
860
- if (entry.handle !== currentHandle &&
861
- opts?.allowCrossSnapshot !== true) {
862
- 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. ` +
863
- 'If the value is counts/strings/shapes rather than ids, pass {allowCrossSnapshot: true} to read it anyway.');
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.');
864
2128
  }
865
- return entry.value;
866
- };
867
- const listSaved = () => listSavedResults();
868
- // Sandbox code holds PROXIED nodes (see wrapNode). The helpers below
869
- // read `retainedSize`, which the proxy refuses on purpose, so they take
870
- // the real node: unwrap at the boundary rather than making every caller
871
- // remember which helpers are proxy-safe.
872
- const helpers = {
873
- serializeNodeSummary: (n) => serializeNodeSummary(unwrapNode(n)),
874
- serializeNodeDetail: (n) => serializeNodeDetail(unwrapNode(n)),
875
- formatBytes,
876
- formatNumber,
877
- markdownTable,
878
- isNodeWorthInspecting: (n, ...rest) => isNodeWorthInspecting(unwrapNode(n), ...rest),
879
- filterLargestObjects: (nodes, ...rest) => filterLargestObjects(Array.isArray(nodes) ? nodes.map(unwrapNode) : nodes, ...rest),
880
- queryNodes,
881
- groupReferrersByEdge,
882
- groupArrayElementsByProperty,
883
- isOrphaned,
884
- countUniqueTargets,
885
- retainedSize,
886
- retainedSizes,
887
- nodeBrief,
888
- mapEntries,
889
- setElements,
890
- props,
891
- getProp,
892
- shapeSignature,
893
- byClass,
894
- byTypename,
895
- withProp,
896
- aggregateRetained,
897
- isRealDetached,
898
- iterByClass,
899
- iterByType,
900
- classCounts,
901
- edgeTarget,
902
- entries,
903
- dominates,
904
- pathBetween,
905
- save,
906
- load,
907
- listSaved,
908
- };
909
- const sandbox = {
910
- snapshot: wrapSnapshot(snapshot, budget),
911
- utils,
912
- helpers,
913
- console: capturedConsole,
914
- result: undefined,
915
- // Standard JS globals
916
- Array,
917
- Object,
918
- Map,
919
- Set,
920
- JSON,
921
- Math,
922
- RegExp,
923
- String,
924
- Number,
925
- Boolean,
926
- Date,
927
- Error,
928
- TypeError,
929
- RangeError,
930
- WeakMap,
931
- WeakSet,
932
- Symbol,
933
- parseInt,
934
- parseFloat,
935
- isNaN,
936
- isFinite,
937
- Infinity,
938
- NaN,
939
- undefined,
940
- };
941
- const context = vm.createContext(sandbox);
942
- const script = new vm.Script(code, { filename: 'memlab_eval' });
943
- // A budget abort is a controlled stop, not a failure: whatever the code
944
- // had already assigned to `result` is still returned, annotated below.
945
- try {
946
- script.runInContext(context, { timeout: timeout_ms });
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 });
2171
+ }
2172
+ catch (err) {
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;
947
2182
  }
948
- catch (err) {
2183
+ else if (!(err instanceof BudgetExceeded)) {
949
2184
  // Keyed on the error itself, never on `budget.exceeded`: code that
950
2185
  // catches the abort and then throws for an unrelated reason must
951
2186
  // still surface that error.
952
- if (!(err instanceof BudgetExceeded))
953
- throw err;
954
- }
955
- // Actionable hint when nothing was assigned to `result` (the #1 user
956
- // error — code that `return`s a value or runs a value-returning IIFE
957
- // never populates `result`, so output is silently "undefined").
958
- if (sandbox.result === undefined &&
959
- consoleOutput.length === 0 &&
960
- !budget.exceeded) {
961
- return toolResult('Your code ran without error but never assigned to `result`, so there is nothing to return.\n' +
962
- 'Assign the value you want back to `result` (do NOT use `return` at the top level), e.g.:\n' +
963
- ' `result = someValue;`\n' +
964
- 'Use mode:"describe_env" to see the full calling convention.');
965
- }
966
- // `undefined` is never worth persisting: on reload it is
967
- // indistinguishable from a name that was never saved, and the usual
968
- // cause is the "never assigned to `result`" mistake — which the hint
969
- // above only catches when the run produced no console output.
970
- const nothingToSave = sandbox.result === undefined;
971
- if (save_as != null && !budget.exceeded && !nothingToSave) {
972
- setSavedResult(save_as, sandbox.result, currentHandle);
973
- }
974
- let output;
975
- try {
976
- output = JSON.stringify(sandbox.result, null, 2) ?? 'undefined';
2187
+ throw err;
977
2188
  }
978
- catch {
979
- output = String(sandbox.result);
980
- }
981
- output = truncate(output, MAX_OUTPUT_SIZE);
982
- if (consoleOutput.length > 0) {
983
- const consolePart = truncate(consoleOutput.join('\n'), MAX_OUTPUT_SIZE - output.length > 1024 ? 4096 : 1024);
984
- output += '\n\n--- console output ---\n' + consolePart;
2189
+ }
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.`);
985
2270
  }
986
- const footer = [];
987
- if (budget.exceeded) {
988
- footer.push(`⚠️ Walk aborted after ${formatNumber(budget.max)} node visits (max_nodes). The value above is PARTIAL. ` +
989
- 'Raise max_nodes, or narrow the scan with an indexed helper (`helpers.byClass` / `byTypename` / `withProp`) instead of a full `snapshot.nodes` walk.');
990
- if (save_as != null) {
991
- footer.push(`Not saved as "${save_as}" a partial result would be indistinguishable from a complete one on reload.`);
992
- }
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.');
993
2286
  }
994
- else if (budget.visited > 0) {
995
- footer.push(`nodes_visited: ${formatNumber(budget.visited)}`);
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.`);
996
2291
  }
997
- if (save_as != null && !budget.exceeded) {
998
- footer.push(nothingToSave
999
- ? `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.`
1000
- : `Saved as "${save_as}" read it back in a later call with \`helpers.load("${save_as}")\`.`);
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.`);
1001
2303
  }
1002
- if (footer.length > 0) {
1003
- output += '\n\n--- ' + footer.join('\n');
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".');
1004
2322
  }
1005
- return toolResult(output);
1006
2323
  }
1007
- catch (err) {
1008
- return errorResult(new Error(actionableEvalError(err, code)));
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}")\`.`);
1009
2328
  }
1010
- });
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'));
1011
2361
  }
1012
2362
  // Map the opaque VM errors that the documented calling-convention mistakes
1013
2363
  // produce into actionable guidance (Feedback §3).
1014
2364
  function actionableEvalError(err, code) {
1015
2365
  const msg = err instanceof Error ? err.message : String(err);
1016
- if (msg.includes('Script execution timed out')) {
2366
+ if (isScriptTimeout(err)) {
1017
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).`;
1018
2368
  }
1019
2369
  if (msg.includes('Illegal return statement')) {
@@ -1097,7 +2447,200 @@ function describeSaved() {
1097
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.',
1098
2448
  ].join('\n');
1099
2449
  }
1100
- function describeEnv() {
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() {
1101
2644
  return [
1102
2645
  '# memlab_eval environment',
1103
2646
  '',
@@ -1114,19 +2657,36 @@ function describeEnv() {
1114
2657
  '',
1115
2658
  '## Collection / shape / index helpers (prefer these over hand-rolling)',
1116
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}`.',
1117
- "- `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.",
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).',
1118
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.',
1119
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.',
1120
2666
  '- `helpers.iterByClass(name, {type?}) -> node[]` / `helpers.iterByType(type) -> node[]` — indexed iteration; no full scan, index built once per snapshot.',
1121
2667
  '- `helpers.classCounts({pattern?, type?, minCount?}) -> [{name, type, count, selfSize}]` — one-pass class histogram, cached; `pattern` is a case-insensitive regex (substring fallback).',
1122
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.',
1123
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.',
1124
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.',
1125
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.',
1126
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".',
1127
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.',
1128
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).',
1129
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
+ '',
1130
2690
  '## Named result sets (multi-step exploration)',
1131
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.',
1132
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.',
@@ -1148,12 +2708,21 @@ function describeEnv() {
1148
2708
  '## IHeapEdge API',
1149
2709
  '`.name_or_index`, `.type` (property/element/context/internal/hidden/shortcut), `.toNode`, `.fromNode`.',
1150
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
+ '',
1151
2720
  '## Runnable example',
1152
2721
  '```',
1153
2722
  'const counts = {};',
1154
2723
  'snapshot.nodes.forEach(node => { counts[node.type] = (counts[node.type] || 0) + 1; });',
1155
2724
  'result = counts;',
1156
2725
  '```',
1157
- ].join('\n');
2726
+ ];
1158
2727
  }
1159
2728
  //# sourceMappingURL=eval.js.map