@memlab/mcp-server 2.1.1 → 2.18.1

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 (175) hide show
  1. package/README.md +83 -3
  2. package/dist/analysis-budget.d.ts +71 -0
  3. package/dist/analysis-budget.d.ts.map +1 -0
  4. package/dist/analysis-budget.js +118 -0
  5. package/dist/analysis-budget.js.map +1 -0
  6. package/dist/guardrail.d.ts +24 -0
  7. package/dist/guardrail.d.ts.map +1 -0
  8. package/dist/guardrail.js +65 -0
  9. package/dist/guardrail.js.map +1 -0
  10. package/dist/heap-state.d.ts +53 -1
  11. package/dist/heap-state.d.ts.map +1 -1
  12. package/dist/heap-state.js +153 -10
  13. package/dist/heap-state.js.map +1 -1
  14. package/dist/index.js +38 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/tools/array-group-by.d.ts +12 -0
  17. package/dist/tools/array-group-by.d.ts.map +1 -0
  18. package/dist/tools/array-group-by.js +191 -0
  19. package/dist/tools/array-group-by.js.map +1 -0
  20. package/dist/tools/auto-investigate.d.ts.map +1 -1
  21. package/dist/tools/auto-investigate.js +1056 -52
  22. package/dist/tools/auto-investigate.js.map +1 -1
  23. package/dist/tools/cache-analysis.d.ts.map +1 -1
  24. package/dist/tools/cache-analysis.js +438 -21
  25. package/dist/tools/cache-analysis.js.map +1 -1
  26. package/dist/tools/check-health.d.ts.map +1 -1
  27. package/dist/tools/check-health.js +261 -1
  28. package/dist/tools/check-health.js.map +1 -1
  29. package/dist/tools/class-histogram.d.ts.map +1 -1
  30. package/dist/tools/class-histogram.js +142 -33
  31. package/dist/tools/class-histogram.js.map +1 -1
  32. package/dist/tools/closure-inspection.d.ts.map +1 -1
  33. package/dist/tools/closure-inspection.js +238 -25
  34. package/dist/tools/closure-inspection.js.map +1 -1
  35. package/dist/tools/detached-dom.d.ts.map +1 -1
  36. package/dist/tools/detached-dom.js +25 -3
  37. package/dist/tools/detached-dom.js.map +1 -1
  38. package/dist/tools/dev-artifacts.d.ts +33 -0
  39. package/dist/tools/dev-artifacts.d.ts.map +1 -0
  40. package/dist/tools/dev-artifacts.js +165 -0
  41. package/dist/tools/dev-artifacts.js.map +1 -0
  42. package/dist/tools/diff-snapshots.d.ts.map +1 -1
  43. package/dist/tools/diff-snapshots.js +200 -48
  44. package/dist/tools/diff-snapshots.js.map +1 -1
  45. package/dist/tools/duplicated-strings.d.ts.map +1 -1
  46. package/dist/tools/duplicated-strings.js +74 -12
  47. package/dist/tools/duplicated-strings.js.map +1 -1
  48. package/dist/tools/eval.d.ts.map +1 -1
  49. package/dist/tools/eval.js +189 -10
  50. package/dist/tools/eval.js.map +1 -1
  51. package/dist/tools/event-listener-leaks.d.ts.map +1 -1
  52. package/dist/tools/event-listener-leaks.js +235 -13
  53. package/dist/tools/event-listener-leaks.js.map +1 -1
  54. package/dist/tools/event-registry.d.ts +12 -0
  55. package/dist/tools/event-registry.d.ts.map +1 -0
  56. package/dist/tools/event-registry.js +183 -0
  57. package/dist/tools/event-registry.js.map +1 -0
  58. package/dist/tools/find-by-property.d.ts.map +1 -1
  59. package/dist/tools/find-by-property.js +113 -31
  60. package/dist/tools/find-by-property.js.map +1 -1
  61. package/dist/tools/find-by-shape.d.ts +12 -0
  62. package/dist/tools/find-by-shape.d.ts.map +1 -0
  63. package/dist/tools/find-by-shape.js +178 -0
  64. package/dist/tools/find-by-shape.js.map +1 -0
  65. package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
  66. package/dist/tools/find-nodes-by-class.js +50 -6
  67. package/dist/tools/find-nodes-by-class.js.map +1 -1
  68. package/dist/tools/for-each.d.ts.map +1 -1
  69. package/dist/tools/for-each.js +1 -1
  70. package/dist/tools/for-each.js.map +1 -1
  71. package/dist/tools/get-node.d.ts.map +1 -1
  72. package/dist/tools/get-node.js +6 -0
  73. package/dist/tools/get-node.js.map +1 -1
  74. package/dist/tools/get-references.d.ts.map +1 -1
  75. package/dist/tools/get-references.js +35 -6
  76. package/dist/tools/get-references.js.map +1 -1
  77. package/dist/tools/get-referrers.d.ts.map +1 -1
  78. package/dist/tools/get-referrers.js +35 -6
  79. package/dist/tools/get-referrers.js.map +1 -1
  80. package/dist/tools/get-string.d.ts +12 -0
  81. package/dist/tools/get-string.d.ts.map +1 -0
  82. package/dist/tools/get-string.js +223 -0
  83. package/dist/tools/get-string.js.map +1 -0
  84. package/dist/tools/get-value.d.ts +12 -0
  85. package/dist/tools/get-value.d.ts.map +1 -0
  86. package/dist/tools/get-value.js +152 -0
  87. package/dist/tools/get-value.js.map +1 -0
  88. package/dist/tools/global-variables.d.ts.map +1 -1
  89. package/dist/tools/global-variables.js +54 -34
  90. package/dist/tools/global-variables.js.map +1 -1
  91. package/dist/tools/growth-signals.d.ts +12 -0
  92. package/dist/tools/growth-signals.d.ts.map +1 -0
  93. package/dist/tools/growth-signals.js +211 -0
  94. package/dist/tools/growth-signals.js.map +1 -0
  95. package/dist/tools/intern-opportunities.d.ts +12 -0
  96. package/dist/tools/intern-opportunities.d.ts.map +1 -0
  97. package/dist/tools/intern-opportunities.js +870 -0
  98. package/dist/tools/intern-opportunities.js.map +1 -0
  99. package/dist/tools/largest-objects.d.ts.map +1 -1
  100. package/dist/tools/largest-objects.js +37 -14
  101. package/dist/tools/largest-objects.js.map +1 -1
  102. package/dist/tools/load-snapshot.d.ts +70 -0
  103. package/dist/tools/load-snapshot.d.ts.map +1 -1
  104. package/dist/tools/load-snapshot.js +446 -17
  105. package/dist/tools/load-snapshot.js.map +1 -1
  106. package/dist/tools/object-cost-breakdown.d.ts +12 -0
  107. package/dist/tools/object-cost-breakdown.d.ts.map +1 -0
  108. package/dist/tools/object-cost-breakdown.js +439 -0
  109. package/dist/tools/object-cost-breakdown.js.map +1 -0
  110. package/dist/tools/object-shape.d.ts.map +1 -1
  111. package/dist/tools/object-shape.js +109 -2
  112. package/dist/tools/object-shape.js.map +1 -1
  113. package/dist/tools/pinch-points.d.ts.map +1 -1
  114. package/dist/tools/pinch-points.js +9 -9
  115. package/dist/tools/pinch-points.js.map +1 -1
  116. package/dist/tools/property-distribution.d.ts +12 -0
  117. package/dist/tools/property-distribution.d.ts.map +1 -0
  118. package/dist/tools/property-distribution.js +168 -0
  119. package/dist/tools/property-distribution.js.map +1 -0
  120. package/dist/tools/quick-diagnosis.d.ts.map +1 -1
  121. package/dist/tools/quick-diagnosis.js +65 -20
  122. package/dist/tools/quick-diagnosis.js.map +1 -1
  123. package/dist/tools/retainer-summary.d.ts.map +1 -1
  124. package/dist/tools/retainer-summary.js +90 -5
  125. package/dist/tools/retainer-summary.js.map +1 -1
  126. package/dist/tools/retainer-trace.d.ts.map +1 -1
  127. package/dist/tools/retainer-trace.js +173 -43
  128. package/dist/tools/retainer-trace.js.map +1 -1
  129. package/dist/tools/search-nodes.d.ts.map +1 -1
  130. package/dist/tools/search-nodes.js +10 -3
  131. package/dist/tools/search-nodes.js.map +1 -1
  132. package/dist/tools/search-strings.d.ts +12 -0
  133. package/dist/tools/search-strings.d.ts.map +1 -0
  134. package/dist/tools/search-strings.js +182 -0
  135. package/dist/tools/search-strings.js.map +1 -0
  136. package/dist/tools/sequence-analysis.d.ts +12 -0
  137. package/dist/tools/sequence-analysis.d.ts.map +1 -0
  138. package/dist/tools/sequence-analysis.js +244 -0
  139. package/dist/tools/sequence-analysis.js.map +1 -0
  140. package/dist/tools/server-status.d.ts +12 -0
  141. package/dist/tools/server-status.d.ts.map +1 -0
  142. package/dist/tools/server-status.js +41 -0
  143. package/dist/tools/server-status.js.map +1 -0
  144. package/dist/tools/shape-histogram.d.ts.map +1 -1
  145. package/dist/tools/shape-histogram.js +61 -16
  146. package/dist/tools/shape-histogram.js.map +1 -1
  147. package/dist/tools/sliced-strings.d.ts.map +1 -1
  148. package/dist/tools/sliced-strings.js +38 -15
  149. package/dist/tools/sliced-strings.js.map +1 -1
  150. package/dist/tools/snapshot-header.d.ts +28 -0
  151. package/dist/tools/snapshot-header.d.ts.map +1 -0
  152. package/dist/tools/snapshot-header.js +111 -0
  153. package/dist/tools/snapshot-header.js.map +1 -0
  154. package/dist/tools/snapshot-summary.d.ts.map +1 -1
  155. package/dist/tools/snapshot-summary.js +55 -13
  156. package/dist/tools/snapshot-summary.js.map +1 -1
  157. package/dist/tools/snapshots.d.ts +12 -0
  158. package/dist/tools/snapshots.d.ts.map +1 -0
  159. package/dist/tools/snapshots.js +93 -0
  160. package/dist/tools/snapshots.js.map +1 -0
  161. package/dist/tools/stale-collections.d.ts.map +1 -1
  162. package/dist/tools/stale-collections.js +248 -7
  163. package/dist/tools/stale-collections.js.map +1 -1
  164. package/dist/tools/trace-dominators.d.ts.map +1 -1
  165. package/dist/tools/trace-dominators.js +28 -1
  166. package/dist/tools/trace-dominators.js.map +1 -1
  167. package/dist/tools/weakmap-entries.d.ts +12 -0
  168. package/dist/tools/weakmap-entries.d.ts.map +1 -0
  169. package/dist/tools/weakmap-entries.js +156 -0
  170. package/dist/tools/weakmap-entries.js.map +1 -0
  171. package/dist/utils.d.ts +139 -2
  172. package/dist/utils.d.ts.map +1 -1
  173. package/dist/utils.js +448 -35
  174. package/dist/utils.js.map +1 -1
  175. package/package.json +3 -3
@@ -7,9 +7,11 @@
7
7
  * @format
8
8
  * @oncall memory_lab
9
9
  */
10
+ import memlabCore from '@memlab/core';
11
+ const { utils, NumericSet } = memlabCore;
10
12
  import { z } from 'zod';
11
13
  import { getSnapshot, getSnapshotMetadata } from '../heap-state.js';
12
- import { filterLargestObjects, isNodeWorthInspecting, formatBytes, formatNumber, truncateNodeName, errorResult, toolResult, } from '../utils.js';
14
+ import { filterLargestObjects, isNodeWorthInspecting, formatBytes, formatNumber, formatRetainerTree, truncateNodeName, instrumentationRetainerNote, errorResult, toolResult, suggestionsSuppressed, } from '../utils.js';
13
15
  function classifySeverity(retainedSize, totalSize) {
14
16
  if (totalSize === 0)
15
17
  return 'LOW';
@@ -244,6 +246,47 @@ function scanTopShapes(snapshot, limit) {
244
246
  .sort((a, b) => b.totalSelfSize - a.totalSelfSize)
245
247
  .slice(0, limit);
246
248
  }
249
+ function scanDistributedAccumulation(snapshot, totalSize) {
250
+ const classMap = new Map();
251
+ snapshot.nodes.forEach(node => {
252
+ if (node.id <= 3)
253
+ return;
254
+ if (node.type === 'hidden' || node.type === 'array')
255
+ return;
256
+ const key = `${node.type}::${node.name}`;
257
+ const existing = classMap.get(key);
258
+ if (existing) {
259
+ existing.count++;
260
+ existing.totalSelfSize += node.self_size;
261
+ }
262
+ else {
263
+ classMap.set(key, {
264
+ className: node.name,
265
+ nodeType: node.type,
266
+ count: 1,
267
+ totalSelfSize: node.self_size,
268
+ exampleNodeId: node.id,
269
+ });
270
+ }
271
+ });
272
+ return [...classMap.values()]
273
+ .filter(c => {
274
+ if (c.count < 50_000)
275
+ return false;
276
+ const avgSize = c.totalSelfSize / c.count;
277
+ if (avgSize > 10_000)
278
+ return false;
279
+ if (totalSize > 0 && c.totalSelfSize < totalSize * 0.03)
280
+ return false;
281
+ return true;
282
+ })
283
+ .map(c => ({
284
+ ...c,
285
+ avgSize: c.totalSelfSize / c.count,
286
+ }))
287
+ .sort((a, b) => b.totalSelfSize - a.totalSelfSize)
288
+ .slice(0, 5);
289
+ }
247
290
  const LISTENER_SHAPE_KEYWORDS = new Set([
248
291
  'callback',
249
292
  'handler',
@@ -264,7 +307,15 @@ const LISTENER_CONTEXT_KEYWORDS = new Set([
264
307
  'scope',
265
308
  'self',
266
309
  ]);
267
- function scanSubscriptionAccumulation(snapshot, threshold) {
310
+ function scanSubscriptionAccumulation(snapshot, threshold, totalSize) {
311
+ const CALLBACK_PROPS = new Set(['callback', 'fn', 'handler', 'listener']);
312
+ const CONTEXT_PROPS_SET = new Set([
313
+ 'context',
314
+ 'ctx',
315
+ 'this',
316
+ 'target',
317
+ 'scope',
318
+ ]);
268
319
  const shapeMap = new Map();
269
320
  snapshot.nodes.forEach(node => {
270
321
  if (node.type !== 'object' || node.id <= 3)
@@ -272,9 +323,20 @@ function scanSubscriptionAccumulation(snapshot, threshold) {
272
323
  if (node.name !== 'Object')
273
324
  return;
274
325
  const names = [];
326
+ let callbackId = 0;
327
+ let contextId = 0;
328
+ let contextName = '';
275
329
  for (const edge of node.references) {
276
330
  if (edge.type === 'property') {
277
- names.push(String(edge.name_or_index));
331
+ const pName = String(edge.name_or_index);
332
+ names.push(pName);
333
+ if (CALLBACK_PROPS.has(pName)) {
334
+ callbackId = edge.toNode.id;
335
+ }
336
+ if (CONTEXT_PROPS_SET.has(pName)) {
337
+ contextId = edge.toNode.id;
338
+ contextName = edge.toNode.name;
339
+ }
278
340
  }
279
341
  }
280
342
  if (names.length === 0 || names.length > 10)
@@ -285,10 +347,29 @@ function scanSubscriptionAccumulation(snapshot, threshold) {
285
347
  if (existing) {
286
348
  existing.count++;
287
349
  existing.totalSelfSize += node.self_size;
350
+ if (callbackId > 0)
351
+ existing.callbackIds.add(callbackId);
352
+ if (contextId > 0) {
353
+ existing.contextIds.add(contextId);
354
+ existing.contextShapeMap.set(contextName, (existing.contextShapeMap.get(contextName) ?? 0) + 1);
355
+ const ctxNode = snapshot.getNodeById(contextId);
356
+ if (ctxNode && ctxNode.numOfReferrers <= 2) {
357
+ existing.orphanedContexts++;
358
+ }
359
+ }
288
360
  }
289
361
  else {
290
362
  const hasListener = names.some(n => LISTENER_SHAPE_KEYWORDS.has(n) || n.startsWith('on'));
291
363
  const hasContext = names.some(n => LISTENER_CONTEXT_KEYWORDS.has(n));
364
+ const contextShapeMap = new Map();
365
+ let orphanedContexts = 0;
366
+ if (contextId > 0) {
367
+ contextShapeMap.set(contextName, 1);
368
+ const ctxNode = snapshot.getNodeById(contextId);
369
+ if (ctxNode && ctxNode.numOfReferrers <= 2) {
370
+ orphanedContexts = 1;
371
+ }
372
+ }
292
373
  shapeMap.set(key, {
293
374
  properties: names,
294
375
  count: 1,
@@ -296,12 +377,47 @@ function scanSubscriptionAccumulation(snapshot, threshold) {
296
377
  exampleNodeId: node.id,
297
378
  hasListenerProp: hasListener,
298
379
  hasContextProp: hasContext,
380
+ callbackIds: new Set(callbackId > 0 ? [callbackId] : []),
381
+ contextIds: new Set(contextId > 0 ? [contextId] : []),
382
+ contextShapeMap,
383
+ orphanedContexts,
299
384
  });
300
385
  }
301
386
  });
302
- return [...shapeMap.values()]
387
+ const results = [...shapeMap.values()]
303
388
  .filter(s => s.count >= threshold && s.hasListenerProp && s.hasContextProp)
304
389
  .sort((a, b) => b.totalSelfSize - a.totalSelfSize);
390
+ return results.map(s => {
391
+ const hasHighFanOut = s.callbackIds.size <= 5 && s.contextIds.size > 10;
392
+ const hasOrphanedContexts = s.orphanedContexts > s.contextIds.size * 0.5;
393
+ let fanOut;
394
+ if (hasHighFanOut || hasOrphanedContexts) {
395
+ let escalatedSeverity = classifySeverity(s.totalSelfSize, totalSize);
396
+ if (hasHighFanOut &&
397
+ hasOrphanedContexts &&
398
+ (escalatedSeverity === 'LOW' || escalatedSeverity === 'MEDIUM')) {
399
+ escalatedSeverity = s.orphanedContexts > 100 ? 'CRITICAL' : 'HIGH';
400
+ }
401
+ const contextShapes = [...s.contextShapeMap.entries()]
402
+ .map(([name, count]) => ({ name, count }))
403
+ .sort((a, b) => b.count - a.count)
404
+ .slice(0, 5);
405
+ fanOut = {
406
+ uniqueCallbacks: s.callbackIds.size,
407
+ uniqueContexts: s.contextIds.size,
408
+ orphanedContexts: s.orphanedContexts,
409
+ contextShapes,
410
+ escalatedSeverity,
411
+ };
412
+ }
413
+ return {
414
+ properties: s.properties,
415
+ count: s.count,
416
+ totalSelfSize: s.totalSelfSize,
417
+ exampleNodeId: s.exampleNodeId,
418
+ fanOut,
419
+ };
420
+ });
305
421
  }
306
422
  const ERROR_CLASS_NAMES = new Set([
307
423
  'Error',
@@ -354,9 +470,46 @@ function scanErrorAccumulation(snapshot, minCount) {
354
470
  function tracePrefix(trace) {
355
471
  return trace.map(s => `${s.name}(${s.type})`).join('->');
356
472
  }
473
+ /**
474
+ * True if `ancestor` dominates `descendant` in the dominator tree (i.e. all
475
+ * paths from the GC root to `descendant` pass through `ancestor`, so
476
+ * `ancestor.retainedSize` already includes `descendant.retainedSize`). Walks
477
+ * the unique dominator chain upward, bounded to avoid pathological depth.
478
+ */
479
+ function isDominatorAncestor(ancestor, descendant, maxWalk = 500) {
480
+ if (ancestor.id === descendant.id)
481
+ return false;
482
+ let cur = descendant.dominatorNode ?? null;
483
+ let steps = 0;
484
+ while (cur && steps < maxWalk) {
485
+ if (cur.id === ancestor.id)
486
+ return true;
487
+ if (cur.dominatorNode?.id === cur.id)
488
+ break; // reached root self-loop
489
+ cur = cur.dominatorNode ?? null;
490
+ steps++;
491
+ }
492
+ return false;
493
+ }
494
+ /**
495
+ * Correct combined retained size for a set of nodes that may overlap on the
496
+ * dominator tree. In a dominator tree two nodes' retained subtrees are either
497
+ * nested or disjoint, so only dominator-maximal members are counted (any node
498
+ * dominated by another member has its bytes already included). This never
499
+ * exceeds the heap total — fixing the ">100% of heap" accounting.
500
+ *
501
+ * Delegates to memlab's `aggregateDominatorMetrics`, which dedups by walking
502
+ * each node's dominator chain once against a set — O(N·D) (N = nodes, D =
503
+ * dominator depth), versus the previous O(N²·D) pairwise `isDominatorAncestor`
504
+ * scan.
505
+ */
506
+ function combinedRetainedSize(nodes, snapshot) {
507
+ return utils.aggregateDominatorMetrics(new NumericSet(nodes.map(n => n.id)), snapshot, () => true, (node) => node.retainedSize);
508
+ }
357
509
  function deduplicateFindings(findings) {
358
510
  if (findings.length <= 1)
359
511
  return findings;
512
+ // Phase 1: Collapse findings that share the same retainer trace prefix
360
513
  const prefixMap = new Map();
361
514
  for (const f of findings) {
362
515
  const collapsed = f.trace.filter(s => s.type !== 'hidden' &&
@@ -384,12 +537,54 @@ function deduplicateFindings(findings) {
384
537
  prefixMap.set(prefix, { representative: f, others: [] });
385
538
  }
386
539
  }
387
- const result = [];
540
+ const prefixResult = [];
388
541
  for (const { representative, others } of prefixMap.values()) {
389
542
  representative.collapsed_siblings = others;
390
- result.push(representative);
543
+ prefixResult.push(representative);
391
544
  }
392
- return result;
545
+ // Phase 2: Merge findings that retain the *same dominated subtree* — whether
546
+ // because one node dominates the other in the dominator tree (so their
547
+ // retained sizes overlap) or because the same big subtree is reachable two
548
+ // ways and shows up under two different top retainers (Feedback §2a, §1a).
549
+ // The larger-retained node becomes the representative; the other is recorded
550
+ // as an alternate path so we report ONE finding instead of two ~equal ones.
551
+ if (prefixResult.length <= 1)
552
+ return prefixResult;
553
+ const traceNodeSets = prefixResult.map(f => {
554
+ const ids = new Set();
555
+ for (const step of f.trace) {
556
+ ids.add(step.nodeId);
557
+ }
558
+ return ids;
559
+ });
560
+ const absorbed = new Set();
561
+ for (let i = 0; i < prefixResult.length; i++) {
562
+ if (absorbed.has(i))
563
+ continue;
564
+ for (let j = i + 1; j < prefixResult.length; j++) {
565
+ if (absorbed.has(j))
566
+ continue;
567
+ const a = prefixResult[i].node;
568
+ const b = prefixResult[j].node;
569
+ const related = isDominatorAncestor(a, b) ||
570
+ isDominatorAncestor(b, a) ||
571
+ traceNodeSets[i].has(b.id) ||
572
+ traceNodeSets[j].has(a.id);
573
+ if (!related)
574
+ continue;
575
+ // Keep the larger-retained finding as representative.
576
+ const keepIdx = a.retainedSize >= b.retainedSize ? i : j;
577
+ const dropIdx = keepIdx === i ? j : i;
578
+ const siblings = prefixResult[keepIdx].collapsed_siblings ?? [];
579
+ siblings.push(prefixResult[dropIdx]);
580
+ siblings.push(...(prefixResult[dropIdx].collapsed_siblings ?? []));
581
+ prefixResult[keepIdx].collapsed_siblings = siblings;
582
+ absorbed.add(dropIdx);
583
+ if (dropIdx === i)
584
+ break; // i itself was absorbed; advance outer loop
585
+ }
586
+ }
587
+ return prefixResult.filter((_, idx) => !absorbed.has(idx));
393
588
  }
394
589
  function formatTrace(trace, maxSteps) {
395
590
  const collapsed = [];
@@ -403,22 +598,558 @@ function formatTrace(trace, maxSteps) {
403
598
  continue;
404
599
  collapsed.push(step);
405
600
  }
406
- const shown = collapsed.length <= maxSteps
407
- ? collapsed
408
- : [...collapsed.slice(0, 3), ...collapsed.slice(-2)];
409
- const parts = [];
410
- for (let i = 0; i < shown.length; i++) {
411
- const s = shown[i];
412
- const name = truncateNodeName(s.name, s.type, s.selfSize, 50);
413
- parts.push(`@${s.nodeId} ${name} (${s.type}) [${formatBytes(s.retainedSize)}]`);
414
- if (i === 2 && collapsed.length > maxSteps) {
415
- parts.push(` … ${collapsed.length - 5} more nodes …`);
416
- }
417
- if (i < shown.length - 1 && s.edgeName != null) {
418
- parts[parts.length - 1] += ` --${s.edgeName}-->`;
601
+ const overLimit = collapsed.length > maxSteps;
602
+ const shown = overLimit
603
+ ? [...collapsed.slice(0, 3), ...collapsed.slice(-2)]
604
+ : collapsed;
605
+ // Render as a top-down indented tree so the retention direction is
606
+ // unambiguous (GC root on top, each node retained by the one above it).
607
+ // `s.edgeName` is the OUTGOING edge (s -> next); the edge pointing INTO a node
608
+ // is the previous node's edgeName, which is what the renderer surfaces.
609
+ const treeSteps = shown.map((s, i) => ({
610
+ id: s.nodeId,
611
+ name: truncateNodeName(s.name, s.type, s.selfSize, 50),
612
+ type: s.type,
613
+ retainedSize: s.retainedSize,
614
+ // `shown[i - 1].edgeName` is the previous node's OUTGOING edge = the edge
615
+ // INTO this node, but only when the two are actually adjacent. At the
616
+ // truncation boundary (i === 3) `shown[2]`'s outgoing edge points into the
617
+ // elided `collapsed[3]`, not into this tail node, so leave it undefined
618
+ // rather than misattribute an unrelated edge — the gap is already marked by
619
+ // `collapsedBefore`.
620
+ edgeName: i > 0 && !(overLimit && i === 3) ? shown[i - 1].edgeName : undefined,
621
+ // The two tail nodes were sliced off the end, so the hop into the first of
622
+ // them crosses the elided middle — mark the gap.
623
+ collapsedBefore: overLimit && i === 3 ? collapsed.length - 5 : undefined,
624
+ }));
625
+ return formatRetainerTree(treeSteps, { showSizes: true });
626
+ }
627
+ function computeTraceOverlap(a, b) {
628
+ let prefixLen = 0;
629
+ const minLen = Math.min(a.length, b.length);
630
+ for (let i = 0; i < minLen; i++) {
631
+ if (a[i].nodeId === b[i].nodeId) {
632
+ prefixLen++;
633
+ }
634
+ else {
635
+ break;
636
+ }
637
+ }
638
+ return { prefixLen, matchIndex: -1 };
639
+ }
640
+ function findBestTraceMatch(trace, previousTraces) {
641
+ let bestMatch = null;
642
+ for (let j = 0; j < previousTraces.length; j++) {
643
+ const { prefixLen } = computeTraceOverlap(trace, previousTraces[j]);
644
+ if (prefixLen > trace.length * 0.5) {
645
+ if (!bestMatch || prefixLen > bestMatch.prefixLen) {
646
+ bestMatch = { matchedIndex: j, prefixLen };
647
+ }
648
+ }
649
+ }
650
+ return bestMatch;
651
+ }
652
+ function detectAsyncContextLeaks(findings) {
653
+ const alerts = [];
654
+ const seen = new Set();
655
+ for (const f of findings) {
656
+ const traceNames = f.trace.map(s => s.name);
657
+ const traceEdges = f.trace.map(s => s.edgeName ?? '');
658
+ // Pattern 1: TCP/Socket → kResourceStore → afterContext (Next.js request context leak)
659
+ const hasTCP = traceNames.some(n => n === 'TCP' || n === 'Socket' || n.includes('TCP'));
660
+ const hasResourceStore = traceEdges.some(e => e === 'kResourceStore' || e === 'resource_symbol');
661
+ const hasAfterContext = traceEdges.some(e => e === 'afterContext');
662
+ const hasOnClose = traceEdges.some(e => e === 'onClose');
663
+ const hasContext = traceNames.some(n => n === 'system / Context' || n.startsWith('system /'));
664
+ if (hasTCP &&
665
+ hasResourceStore &&
666
+ (hasAfterContext || hasOnClose) &&
667
+ hasContext &&
668
+ !seen.has('tcp-als')) {
669
+ seen.add('tcp-als');
670
+ alerts.push(`🔴 **[CRITICAL] AsyncLocalStorage context retained by TCP connection**\n` +
671
+ `A TCP socket's async context chain retains ${formatBytes(f.node.retainedSize)} of request-scoped data. ` +
672
+ `The \`onClose\` closure captures the request's AsyncLocalStorage context, which chains to prior contexts via \`previous\` pointers.\n` +
673
+ `**Fix:** Ensure response bodies are fully consumed/canceled (check \`resp.body.cancel()\`), ` +
674
+ `or avoid capturing large data in request-scoped closures.`);
675
+ }
676
+ // Pattern 2: Generator.parameters_and_registers holding large arrays (Undici response body retention)
677
+ const hasGenerator = traceNames.some(n => n === 'Generator' || n.includes('Generator'));
678
+ const hasParamsAndRegisters = traceEdges.some(e => e === 'parameters_and_registers');
679
+ if (hasGenerator &&
680
+ hasParamsAndRegisters &&
681
+ f.node.retainedSize > 1024 * 1024 &&
682
+ !seen.has('generator-undici')) {
683
+ seen.add('generator-undici');
684
+ alerts.push(`🟠 **[HIGH] Generator retaining large data via \`parameters_and_registers\`**\n` +
685
+ `A Generator object holds ${formatBytes(f.node.retainedSize)} through its parameters/registers. ` +
686
+ `This is a common Undici pattern where an async generator (response body stream) retains the full response buffer.\n` +
687
+ `**Fix:** Ensure response bodies are fully read and the stream is closed/destroyed after use. ` +
688
+ `For fetch(), always call \`resp.text()\`, \`resp.json()\`, or \`resp.body.cancel()\`.`);
689
+ }
690
+ // Pattern 3: PromiseReaction chains with large retained sizes (unresolved/leaked promises)
691
+ const hasPromiseReaction = traceNames.some(n => n === 'PromiseReaction' || n === 'Promise');
692
+ const hasReactionEdge = traceEdges.some(e => e === 'reactions_or_result' ||
693
+ e === 'fulfill_handler' ||
694
+ e === 'reject_handler');
695
+ if (hasPromiseReaction &&
696
+ hasReactionEdge &&
697
+ f.node.retainedSize > 5 * 1024 * 1024 &&
698
+ !seen.has('promise-chain')) {
699
+ seen.add('promise-chain');
700
+ alerts.push(`🟠 **[HIGH] Unresolved Promise chain retaining ${formatBytes(f.node.retainedSize)}**\n` +
701
+ `A PromiseReaction chain is keeping large data alive. This often indicates a stuck async operation ` +
702
+ `(HTTP request, DB query, timer) whose promise was never resolved or rejected.\n` +
703
+ `**Fix:** Add timeouts to async operations. Check for \`await\` on promises that may never resolve. ` +
704
+ `Ensure \`.catch()\` handlers don't capture the entire response.`);
705
+ }
706
+ // Pattern 4: Undici Client/Pool response retention (Feedback #6)
707
+ const hasUndiciClient = traceNames.some(n => n === 'Client' || n === 'Pool' || n === 'Agent');
708
+ const hasRequestQueue = traceEdges.some(e => e === 'requests' || e === 'queue' || e === 'pending' || e === 'running');
709
+ if (hasUndiciClient &&
710
+ hasRequestQueue &&
711
+ f.node.retainedSize > 1024 * 1024 &&
712
+ !seen.has('undici-client')) {
713
+ seen.add('undici-client');
714
+ alerts.push(`🟠 **[HIGH] Undici HTTP Client/Pool retaining ${formatBytes(f.node.retainedSize)} via request queue**\n` +
715
+ `An Undici Client or Pool object is keeping response data alive through its request queue or closure chain. ` +
716
+ `This typically happens when HTTP response bodies are not fully consumed or canceled.\n` +
717
+ `**Fix:** Always consume or cancel response bodies: \`await resp.text()\`, \`resp.body.cancel()\`, ` +
718
+ `or \`resp.body.destroy()\`. For streaming responses, ensure the readable stream is fully drained.`);
719
+ }
720
+ // Pattern 5: mysql2 Connection retaining large data (Feedback #8)
721
+ const hasMysqlConn = traceNames.some(n => n === 'Connection' ||
722
+ n === 'PoolConnection' ||
723
+ n === 'PromiseConnection');
724
+ const hasMysqlEdge = traceEdges.some(e => e === '_protocol' || e === '_statements' || e === 'connectionConfig');
725
+ if (hasMysqlConn &&
726
+ hasMysqlEdge &&
727
+ f.node.retainedSize > 5 * 1024 * 1024 &&
728
+ !seen.has('mysql2-conn')) {
729
+ seen.add('mysql2-conn');
730
+ alerts.push(`🟠 **[HIGH] mysql2 Connection retaining ${formatBytes(f.node.retainedSize)}**\n` +
731
+ `A mysql2 Connection object is keeping query results or protocol buffers alive, often through ` +
732
+ `error event handler closure chains or unreleased prepared statements.\n` +
733
+ `**Fix:** Set pool \`idleTimeout\` (e.g., 60000ms) to free idle connections. ` +
734
+ `Ensure query result rows are not captured in long-lived closures. ` +
735
+ `Consider \`stream()\` instead of \`query()\` for large result sets.`);
736
+ }
737
+ }
738
+ return alerts;
739
+ }
740
+ // Framework class-name signatures. These are scanned heap-wide (not just along
741
+ // the top retainer traces) because framework internals frequently accumulate
742
+ // across many small objects that never surface as a single "top retained
743
+ // object" — yet are high-signal root causes (Feedback §2b).
744
+ const OTEL_METRIC_CLASSES = new Set([
745
+ 'SyncMetricStorage',
746
+ 'AsyncMetricStorage',
747
+ 'TemporalMetricProcessor',
748
+ 'DeltaMetricProcessor',
749
+ 'AttributeHashMap',
750
+ 'HashMap',
751
+ 'MetricStorageRegistry',
752
+ ]);
753
+ const RELAY_STORE_CLASSES = new Set([
754
+ 'RelayModernStore',
755
+ 'RelayRecordSource',
756
+ 'RecordSource',
757
+ 'RelayModernRecord',
758
+ 'RelayReader',
759
+ 'DataChecker',
760
+ ]);
761
+ function mapEntryCount(node) {
762
+ for (const edge of node.references) {
763
+ const n = String(edge.name_or_index);
764
+ if ((n === 'table' || n === 'backing_store') &&
765
+ (edge.toNode.type === 'array' || edge.toNode.type === 'hidden')) {
766
+ return edge.toNode.edge_count;
767
+ }
768
+ }
769
+ return node.edge_count;
770
+ }
771
+ // In-process source-map caches (@jridgewell/trace-mapping). These decode huge
772
+ // nested mapping arrays for stack-trace symbolication and are a recurring
773
+ // top-retainer across the Nest SSR fleet (Feedback round 3 §2a).
774
+ const SOURCEMAP_CLASSES = new Set(['TraceMap', 'AnyMap', 'SourceMapConsumer']);
775
+ function detectFrameworkLeaks(snapshot, totalSize) {
776
+ const alerts = [];
777
+ const otel = new Map();
778
+ const relay = new Map();
779
+ const sourceMaps = {
780
+ count: 0,
781
+ totalRetained: 0,
782
+ decodedRetained: 0,
783
+ encodedRetained: 0,
784
+ sourcesContentRetained: 0,
785
+ exampleId: 0,
786
+ exampleRetained: 0,
787
+ };
788
+ let topGenerator = null;
789
+ let generatorCount = 0;
790
+ const accumulate = (map, node, entries) => {
791
+ const e = map.get(node.name);
792
+ if (e) {
793
+ e.count++;
794
+ e.totalRetained += node.retainedSize;
795
+ e.maxEntries = Math.max(e.maxEntries, entries);
796
+ if (node.retainedSize >
797
+ (snapshot.getNodeById(e.exampleId)?.retainedSize ?? 0)) {
798
+ e.exampleId = node.id;
799
+ }
800
+ }
801
+ else {
802
+ map.set(node.name, {
803
+ count: 1,
804
+ totalRetained: node.retainedSize,
805
+ exampleId: node.id,
806
+ maxEntries: entries,
807
+ });
808
+ }
809
+ };
810
+ snapshot.nodes.forEach(node => {
811
+ if (node.id <= 3)
812
+ return;
813
+ if (OTEL_METRIC_CLASSES.has(node.name) && node.type === 'object') {
814
+ // Find the largest child Map/HashMap (the attribute->aggregation table).
815
+ let entries = 0;
816
+ for (const edge of node.references) {
817
+ const t = edge.toNode;
818
+ if (t.name === 'Map' || t.name === 'HashMap' || t.name === 'Set') {
819
+ entries = Math.max(entries, mapEntryCount(t));
820
+ }
821
+ }
822
+ accumulate(otel, node, entries);
823
+ }
824
+ if (RELAY_STORE_CLASSES.has(node.name) && node.type === 'object') {
825
+ accumulate(relay, node, mapEntryCount(node));
826
+ }
827
+ if (SOURCEMAP_CLASSES.has(node.name) && node.type === 'object') {
828
+ sourceMaps.count++;
829
+ sourceMaps.totalRetained += node.retainedSize;
830
+ if (node.retainedSize > sourceMaps.exampleRetained) {
831
+ sourceMaps.exampleRetained = node.retainedSize;
832
+ sourceMaps.exampleId = node.id;
833
+ }
834
+ for (const edge of node.references) {
835
+ if (edge.type !== 'property')
836
+ continue;
837
+ const p = String(edge.name_or_index);
838
+ if (p === '_decoded') {
839
+ sourceMaps.decodedRetained += edge.toNode.retainedSize;
840
+ }
841
+ else if (p === '_encoded') {
842
+ sourceMaps.encodedRetained += edge.toNode.retainedSize;
843
+ }
844
+ else if (p === 'sourcesContent') {
845
+ sourceMaps.sourcesContentRetained += edge.toNode.retainedSize;
846
+ }
847
+ }
848
+ }
849
+ if (node.name === 'Generator' &&
850
+ node.type === 'object' &&
851
+ node.retainedSize > 1024 * 1024) {
852
+ generatorCount++;
853
+ if (!topGenerator || node.retainedSize > topGenerator.retained) {
854
+ topGenerator = { id: node.id, retained: node.retainedSize };
855
+ }
856
+ }
857
+ });
858
+ // OpenTelemetry metric cardinality explosion.
859
+ const otelTotal = [...otel.values()].reduce((s, e) => s + e.totalRetained, 0);
860
+ const otelMaxEntries = Math.max(0, ...[...otel.values()].map(e => e.maxEntries));
861
+ if (otel.size > 0 &&
862
+ (otelTotal > 5 * 1024 * 1024 ||
863
+ (totalSize > 0 && otelTotal > totalSize * 0.03) ||
864
+ otelMaxEntries >= 1000)) {
865
+ const top = [...otel.entries()]
866
+ .sort((a, b) => b[1].totalRetained - a[1].totalRetained)
867
+ .slice(0, 3)
868
+ .map(([name, e]) => `${name} (${formatNumber(e.count)}×, ${formatBytes(e.totalRetained)}${e.maxEntries > 0 ? `, up to ${formatNumber(e.maxEntries)} attribute keys` : ''}, e.g. @${e.exampleId})`)
869
+ .join('; ');
870
+ alerts.push(`🔴 **[CRITICAL] OpenTelemetry metric cardinality explosion**\n` +
871
+ `OTel metric-storage internals retain ${formatBytes(otelTotal)} via per-attribute aggregation maps: ${top}.\n` +
872
+ `This is the classic Node OTel footgun: a high-cardinality attribute (request id, full URL with ids, user id) is used as a metric dimension, so each distinct value allocates a new aggregation in the \`AttributeHashMap\` / \`_valueMap\`.\n` +
873
+ `**Confirm:** \`memlab_property_distribution\` on the metric attribute shape, or \`memlab_search_strings\` for the serialized attribute keys (e.g. \`[["http.route","/x/<uuid>"]]\`).\n` +
874
+ `**Fix:** remove or bucket high-cardinality attributes (template the route: \`/artifact/:id\`), or add an allowed-attributes view so unbounded dimensions never enter the metric.`);
875
+ }
876
+ // Relay store growth.
877
+ const relayTotal = [...relay.values()].reduce((s, e) => s + e.totalRetained, 0);
878
+ if (relay.size > 0 &&
879
+ (relayTotal > 5 * 1024 * 1024 ||
880
+ (totalSize > 0 && relayTotal > totalSize * 0.05))) {
881
+ const top = [...relay.entries()]
882
+ .sort((a, b) => b[1].totalRetained - a[1].totalRetained)
883
+ .slice(0, 3)
884
+ .map(([name, e]) => `${name} (${formatNumber(e.count)}×, ${formatBytes(e.totalRetained)}, e.g. @${e.exampleId})`)
885
+ .join('; ');
886
+ alerts.push(`🟠 **[HIGH] Relay store retaining ${formatBytes(relayTotal)}**\n` +
887
+ `Relay record source/store internals are holding a large normalized cache: ${top}.\n` +
888
+ `**Fix:** ensure queries are disposed/GC'd (check \`retain()\` without matching \`dispose()\`), enable store garbage collection (\`gcReleaseBufferSize\`), or scope long-lived environments per request instead of a single global store.`);
889
+ }
890
+ // In-process source-map / symbolicator caches.
891
+ if (sourceMaps.count > 0 &&
892
+ (sourceMaps.totalRetained > 2 * 1024 * 1024 ||
893
+ (totalSize > 0 && sourceMaps.totalRetained > totalSize * 0.03))) {
894
+ const parts = [];
895
+ if (sourceMaps.decodedRetained > 0) {
896
+ parts.push(`_decoded ${formatBytes(sourceMaps.decodedRetained)}`);
897
+ }
898
+ if (sourceMaps.encodedRetained > 0) {
899
+ parts.push(`_encoded ${formatBytes(sourceMaps.encodedRetained)}`);
900
+ }
901
+ if (sourceMaps.sourcesContentRetained > 0) {
902
+ parts.push(`sourcesContent ${formatBytes(sourceMaps.sourcesContentRetained)}`);
903
+ }
904
+ const split = parts.length > 0 ? ` (${parts.join(', ')})` : '';
905
+ alerts.push(`🟠 **[HIGH] In-process source-map cache retaining ${formatBytes(sourceMaps.totalRetained)}**\n` +
906
+ `${formatNumber(sourceMaps.count)} \`TraceMap\`/source-map object(s) (\`@jridgewell/trace-mapping\`) are cached in-process for stack-trace symbolication${split}. Largest: @${sourceMaps.exampleId}. ` +
907
+ `The decoded mappings (\`_decoded\`) are huge nested arrays; on the Nest SSR fleet these are typically held by the OTel structured logger, the patched console, or a \`beforeExit\` closure.\n` +
908
+ `**Inspect:** \`memlab_dominator_subtree(${sourceMaps.exampleId})\` to see the decoded arrays; \`memlab_retainer_trace(${sourceMaps.exampleId})\` for who holds the cache.\n` +
909
+ `**Fix:** strip \`sourcesContent\` from the maps, cap/evict the symbolication cache, or move symbolication out-of-process. Every Nest server that symbolicates errors in-process hits this.`);
910
+ }
911
+ // Suspended async functions (generators) retaining large locals.
912
+ if (topGenerator) {
913
+ const g = topGenerator;
914
+ alerts.push(`🟠 **[HIGH] Suspended async function retaining ${formatBytes(g.retained)}**\n` +
915
+ `${formatNumber(generatorCount)} generator/async frame(s) >1 MB are parked mid-\`await\`, keeping their local variables (response buffers, accumulated rows) alive while the awaited resource never settles. Largest: @${g.id}.\n` +
916
+ `**Inspect:** \`memlab_closure_inspection(${g.id})\` resolves the frame's \`parameters_and_registers\` to source variable names.\n` +
917
+ `**Fix:** add timeouts/cancellation to the awaited operation; fully consume or cancel streamed response bodies (\`await resp.text()\` / \`resp.body.cancel()\`); avoid capturing whole datasets in the async frame.`);
918
+ }
919
+ return alerts;
920
+ }
921
+ /**
922
+ * Classify the long-lived GC root a closure hangs off of, by scanning its
923
+ * retainer path for the high-signal structures that pin request-scoped data for
924
+ * a connection/process lifetime (Feedback round 3 §2b/§2c).
925
+ */
926
+ function classifyLongLivedHolder(trace) {
927
+ for (const s of trace) {
928
+ const n = s.name;
929
+ const e = s.edgeName ?? '';
930
+ if (n === 'Timeout' ||
931
+ n === 'TimersList' ||
932
+ e === '_onTimeout' ||
933
+ e === '_idleNext' ||
934
+ e === '_repeat') {
935
+ return 'setInterval/setTimeout timer';
936
+ }
937
+ if (e === 'onClose')
938
+ return 'onClose handler';
939
+ if (n === 'TCP' || n === 'Socket' || n === 'ReusedHandle') {
940
+ return 'keep-alive socket';
941
+ }
942
+ if (e === 'kResourceStore' ||
943
+ e === 'resource_symbol' ||
944
+ /AsyncLocalStorage/.test(n)) {
945
+ return 'AsyncLocalStorage store';
946
+ }
947
+ if (e === '_events' || /EventEmitter/.test(n))
948
+ return 'EventEmitter listener';
949
+ if (e === 'signal' || n === 'AbortSignal')
950
+ return 'AbortSignal listener';
951
+ }
952
+ return null;
953
+ }
954
+ /**
955
+ * Find the single largest variable a closure/context captures — the dominant
956
+ * captured scope member, which is usually what makes an oversized handler
957
+ * closure expensive.
958
+ */
959
+ function dominantCapturedVar(closure) {
960
+ let ctx = null;
961
+ for (const edge of closure.references) {
962
+ if ((edge.type === 'internal' || edge.type === 'context') &&
963
+ String(edge.name_or_index) === 'context') {
964
+ ctx = edge.toNode;
965
+ break;
966
+ }
967
+ }
968
+ let best = null;
969
+ const consider = (node) => {
970
+ for (const edge of node.references) {
971
+ if (edge.type !== 'context' && edge.type !== 'property')
972
+ continue;
973
+ const name = String(edge.name_or_index);
974
+ if (name === 'previous' || name === 'native_context')
975
+ continue;
976
+ const t = edge.toNode;
977
+ if (t.id <= 3)
978
+ continue;
979
+ if (!best || t.retainedSize > best.retained) {
980
+ best = { name, retained: t.retainedSize, targetName: t.name };
981
+ }
982
+ }
983
+ };
984
+ consider(closure);
985
+ if (ctx)
986
+ consider(ctx);
987
+ return best;
988
+ }
989
+ /**
990
+ * Proactively surface the most common Node server leak shape this round: a
991
+ * long-lived closure (setInterval/onClose/listener) that captures a large
992
+ * variable it barely uses, pinning it for the connection/process lifetime.
993
+ * `closure_inspection` already computes the per-variable breakdown — this just
994
+ * flags the handler closures so the model doesn't have to suspect them first
995
+ * (Feedback round 3 §2b).
996
+ */
997
+ function scanOversizedClosures(snapshot, totalSize) {
998
+ const closureMin = Math.max(1024 * 1024, totalSize > 0 ? totalSize * 0.02 : 0);
999
+ const candidates = [];
1000
+ snapshot.nodes.forEach(node => {
1001
+ if (node.type !== 'closure' || node.id <= 3)
1002
+ return;
1003
+ if (node.retainedSize < closureMin)
1004
+ return;
1005
+ candidates.push(node);
1006
+ });
1007
+ candidates.sort((a, b) => b.retainedSize - a.retainedSize);
1008
+ const alerts = [];
1009
+ const seen = new Set();
1010
+ for (const closure of candidates.slice(0, 30)) {
1011
+ const trace = getRetainerPath(closure);
1012
+ const holder = classifyLongLivedHolder(trace);
1013
+ if (!holder)
1014
+ continue;
1015
+ const dom = dominantCapturedVar(closure);
1016
+ const key = `${holder}|${dom?.name ?? ''}`;
1017
+ if (seen.has(key))
1018
+ continue;
1019
+ seen.add(key);
1020
+ const domStr = dom
1021
+ ? ` Its largest captured variable is \`${dom.name}\` → ${dom.targetName} (${formatBytes(dom.retained)})` +
1022
+ (dom.retained > closure.retainedSize * 0.6
1023
+ ? ' — the bulk of the retention, and quite possibly captured but barely used.'
1024
+ : '.')
1025
+ : '';
1026
+ alerts.push(`🟠 **[HIGH] Oversized closure on a long-lived ${holder} retaining ${formatBytes(closure.retainedSize)}**\n` +
1027
+ `Closure @${closure.id}${closure.name ? ` \`${truncateNodeName(closure.name, closure.type, closure.self_size, 40)}\`` : ''} is reachable from a ${holder} and keeps its captured scope alive for that root's lifetime.${domStr}\n` +
1028
+ `**Inspect:** \`memlab_closure_inspection(${closure.id}, {min_retained_size: 102400})\` for the full per-variable capture breakdown.\n` +
1029
+ `**Fix:** extract only the fields the callback needs before registering it (don't close over the whole request scope / serialized blob), or clear the timer / unregister the listener when the request or connection ends.`);
1030
+ if (alerts.length >= 5)
1031
+ break;
1032
+ }
1033
+ return alerts;
1034
+ }
1035
+ // Keys that mark a record as a log/conversation/message entry. An append-only
1036
+ // accumulation of these is a runaway-appender signature (Feedback round 3 §2d).
1037
+ const MESSAGE_RECORD_KEYS = new Set([
1038
+ 'role',
1039
+ 'content',
1040
+ 'message',
1041
+ 'msg',
1042
+ 'timestamp',
1043
+ 'ts',
1044
+ 'level',
1045
+ 'type',
1046
+ 'text',
1047
+ ]);
1048
+ // Fields whose value usually discriminates the record kind, used to test
1049
+ // whether a few values repeat across most records.
1050
+ const DISCRIMINATOR_KEYS = ['role', 'level', 'type', 'message', 'msg'];
1051
+ /**
1052
+ * A property whose value is expected to be unique per record. If such a field
1053
+ * has far fewer distinct values than instances, the SAME records are retained
1054
+ * multiple times (Feedback round 3 §1b).
1055
+ */
1056
+ function looksUniqueKey(name) {
1057
+ return (/^(id|_id|uuid|guid|key|hash|sku|pk|cursor|etag)$/i.test(name) ||
1058
+ /(_id|Id|Key|Uuid|Guid|Hash)$/.test(name));
1059
+ }
1060
+ /**
1061
+ * Detect append-only logs/conversations: a very high count of a uniform record
1062
+ * shape dominated by a few repeated field values (e.g. 696K `{role, content,
1063
+ * timestamp}` objects from a scheduler that re-appends the same system/error
1064
+ * message forever). `growth_signals` misses these because they aren't keyed the
1065
+ * way its heuristic expects (Feedback round 3 §2d).
1066
+ */
1067
+ function scanAppendOnlyRecords(snapshot, totalSize) {
1068
+ const shapeMap = new Map();
1069
+ snapshot.nodes.forEach(node => {
1070
+ if (node.type !== 'object' || node.id <= 3)
1071
+ return;
1072
+ if (node.name !== 'Object')
1073
+ return;
1074
+ const names = [];
1075
+ for (const edge of node.references) {
1076
+ if (edge.type === 'property')
1077
+ names.push(String(edge.name_or_index));
1078
+ }
1079
+ if (names.length === 0 || names.length > 12)
1080
+ return;
1081
+ if (!names.some(n => MESSAGE_RECORD_KEYS.has(n.toLowerCase())))
1082
+ return;
1083
+ names.sort();
1084
+ const key = names.join(',');
1085
+ const ex = shapeMap.get(key);
1086
+ if (ex) {
1087
+ ex.count++;
1088
+ ex.totalSelfSize += node.self_size;
1089
+ if (ex.exampleIds.length < 100)
1090
+ ex.exampleIds.push(node.id);
419
1091
  }
1092
+ else {
1093
+ shapeMap.set(key, {
1094
+ properties: names,
1095
+ count: 1,
1096
+ totalSelfSize: node.self_size,
1097
+ exampleNodeId: node.id,
1098
+ exampleIds: [node.id],
1099
+ });
1100
+ }
1101
+ });
1102
+ const hits = [...shapeMap.values()]
1103
+ .filter(s => s.count >= 50_000)
1104
+ .sort((a, b) => b.count - a.count)
1105
+ .slice(0, 3);
1106
+ const alerts = [];
1107
+ for (const s of hits) {
1108
+ // Test a discriminator field for value concentration over the sample.
1109
+ const field = DISCRIMINATOR_KEYS.find(k => s.properties.includes(k)) ?? null;
1110
+ let concentration = '';
1111
+ if (field) {
1112
+ const freq = new Map();
1113
+ let sampled = 0;
1114
+ for (const id of s.exampleIds) {
1115
+ const node = snapshot.getNodeById(id);
1116
+ if (!node)
1117
+ continue;
1118
+ for (const edge of node.references) {
1119
+ if (edge.type === 'property' &&
1120
+ String(edge.name_or_index) === field) {
1121
+ let val = edge.toNode.name;
1122
+ if (edge.toNode.isString) {
1123
+ const sn = edge.toNode.toStringNode();
1124
+ if (sn)
1125
+ val = sn.stringValue;
1126
+ }
1127
+ freq.set(val, (freq.get(val) ?? 0) + 1);
1128
+ sampled++;
1129
+ break;
1130
+ }
1131
+ }
1132
+ }
1133
+ if (sampled > 0) {
1134
+ const top = [...freq.entries()].sort((a, b) => b[1] - a[1])[0];
1135
+ if (top) {
1136
+ const pct = ((top[1] / sampled) * 100).toFixed(0);
1137
+ const valDisp = top[0].length > 40 ? top[0].slice(0, 40) + '…' : top[0];
1138
+ concentration = ` The \`${field}\` field is dominated by one value ("${valDisp}", ${pct}% of sampled records) — a strong append-only signature.`;
1139
+ }
1140
+ }
1141
+ }
1142
+ const pctHeap = totalSize > 0
1143
+ ? ` (${((s.totalSelfSize / totalSize) * 100).toFixed(1)}% of heap self size)`
1144
+ : '';
1145
+ const propsDisplay = s.properties.length <= 6
1146
+ ? `{${s.properties.join(', ')}}`
1147
+ : `{${s.properties.slice(0, 5).join(', ')}, … +${s.properties.length - 5}}`;
1148
+ alerts.push(`🟠 **[HIGH] Append-only record accumulation — ${formatNumber(s.count)}× ${propsDisplay}**\n` +
1149
+ `${formatNumber(s.count)} uniform message/log records (${formatBytes(s.totalSelfSize)} self${pctHeap}, example @${s.exampleNodeId}).${concentration}\n` +
1150
+ `**Fix:** bound the conversation/log history (ring buffer, max length, or periodic flush). Check for a scheduler/retry loop that re-appends the same system or error message without ever trimming.`);
420
1151
  }
421
- return parts.join('\n → ');
1152
+ return alerts;
422
1153
  }
423
1154
  export function registerAutoInvestigate(server) {
424
1155
  server.tool('memlab_auto_investigate', 'One-shot deep analysis: finds the top retained objects, traces each retainer chain to the GC root, identifies pinch points (small objects retaining large subtrees), and detects unbounded caches. Returns a structured report with root causes and suggested fixes. Use this as the first tool after loading a snapshot to get immediate actionable findings.', {
@@ -482,8 +1213,13 @@ export function registerAutoInvestigate(server) {
482
1213
  topPending: [],
483
1214
  };
484
1215
  const shapes = focus === 'all' ? scanTopShapes(snapshot, 5) : [];
485
- const subscriptions = focus === 'all' ? scanSubscriptionAccumulation(snapshot, 1000) : [];
1216
+ const subscriptions = focus === 'all'
1217
+ ? scanSubscriptionAccumulation(snapshot, 1000, totalSize)
1218
+ : [];
486
1219
  const errorAccum = focus === 'all' ? scanErrorAccumulation(snapshot, 10) : [];
1220
+ const distributed = focus === 'all'
1221
+ ? scanDistributedAccumulation(snapshot, totalSize)
1222
+ : [];
487
1223
  const lines = [`# Auto-Investigation Report`, ''];
488
1224
  const focusLabel = focus === 'all' ? '' : ` (focus: ${focus})`;
489
1225
  lines.push(`## Top ${findings.length} Retained Objects${focusLabel}`);
@@ -504,13 +1240,17 @@ export function registerAutoInvestigate(server) {
504
1240
  }
505
1241
  }
506
1242
  }
1243
+ const previousTraces = [];
507
1244
  for (let i = 0; i < findings.length; i++) {
508
1245
  const f = findings[i];
509
1246
  const siblings = f.collapsed_siblings ?? [];
510
- const totalRetained = f.node.retainedSize +
511
- siblings.reduce((s, sib) => s + sib.node.retainedSize, 0);
1247
+ // Dominator-aware combined size: members on the same chain overlap,
1248
+ // so we must not sum their retained sizes (that produced the old
1249
+ // ">100% of heap" bug). combinedRetainedSize() keeps only
1250
+ // dominator-maximal members.
1251
+ const totalRetained = combinedRetainedSize([f.node, ...siblings.map(s => s.node)], snapshot);
512
1252
  const pct = totalSize > 0
513
- ? ` (${((totalRetained / totalSize) * 100).toFixed(1)}% of heap)`
1253
+ ? ` (${Math.min(100, (totalRetained / totalSize) * 100).toFixed(1)}% of heap)`
514
1254
  : '';
515
1255
  const name = truncateNodeName(f.node.name, f.node.type, f.node.self_size, 60);
516
1256
  const sevIcon = f.severity === 'CRITICAL'
@@ -520,20 +1260,47 @@ export function registerAutoInvestigate(server) {
520
1260
  : f.severity === 'MEDIUM'
521
1261
  ? '🟡'
522
1262
  : '🔵';
1263
+ // Check if this finding's trace shares a common prefix with a previous one
1264
+ const traceMatch = i > 0 ? findBestTraceMatch(f.trace, previousTraces) : null;
523
1265
  if (siblings.length > 0) {
524
- lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] \`${name}\` chain — ${formatBytes(totalRetained)} total across ${1 + siblings.length} nodes${pct}`);
1266
+ lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] \`${name}\` — ${formatBytes(totalRetained)} retained, same subtree reached via ${1 + siblings.length} retainer(s)${pct}`);
525
1267
  lines.push('');
526
- lines.push(`**Retainer chain:** ${formatTrace(f.trace, 8)}`);
1268
+ if (traceMatch) {
1269
+ const divergeStep = f.trace[traceMatch.prefixLen];
1270
+ const divergeEdge = divergeStep?.edgeName ?? '…';
1271
+ const divergeName = divergeStep
1272
+ ? truncateNodeName(divergeStep.name, divergeStep.type, divergeStep.selfSize, 40)
1273
+ : '…';
1274
+ lines.push(`**Retainer chain:** (same chain as #${traceMatch.matchedIndex + 1}) → [${divergeEdge}] ${divergeName} — ${formatBytes(f.node.retainedSize)}`);
1275
+ }
1276
+ else {
1277
+ lines.push(`**Retainer chain:**\n${formatTrace(f.trace, 8)}`);
1278
+ }
527
1279
  lines.push('');
528
- lines.push(`**Same chain contains:** @${f.node.id} (${formatBytes(f.node.retainedSize)})` +
1280
+ lines.push(`**Alternate retainers of the same subtree** (retained sizes overlap — not additive): @${f.node.id} (${formatBytes(f.node.retainedSize)})` +
529
1281
  siblings
530
1282
  .map(sib => `, @${sib.node.id} ${truncateNodeName(sib.node.name, sib.node.type, sib.node.self_size, 30)} (${formatBytes(sib.node.retainedSize)})`)
531
1283
  .join(''));
532
1284
  }
1285
+ else if (traceMatch) {
1286
+ const divergeStep = f.trace[traceMatch.prefixLen];
1287
+ const divergeEdge = divergeStep?.edgeName ?? '…';
1288
+ const divergeName = divergeStep
1289
+ ? truncateNodeName(divergeStep.name, divergeStep.type, divergeStep.selfSize, 40)
1290
+ : '…';
1291
+ lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] @${f.node.id} \`${name}\` (${f.node.type}) — ${formatBytes(f.node.retainedSize)}${pct}`);
1292
+ lines.push('');
1293
+ lines.push(`**Retainer chain:** (same chain as #${traceMatch.matchedIndex + 1}) → [${divergeEdge}] ${divergeName} — ${formatBytes(f.node.retainedSize)}`);
1294
+ }
533
1295
  else {
534
1296
  lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] @${f.node.id} \`${name}\` (${f.node.type}) — ${formatBytes(f.node.retainedSize)}${pct}`);
535
1297
  lines.push('');
536
- lines.push(`**Retainer chain:** ${formatTrace(f.trace, 8)}`);
1298
+ lines.push(`**Retainer chain:**\n${formatTrace(f.trace, 8)}`);
1299
+ }
1300
+ previousTraces.push(f.trace);
1301
+ const instrNote = instrumentationRetainerNote(f.trace);
1302
+ if (instrNote) {
1303
+ lines.push(`> ⚠ ${instrNote}`);
537
1304
  }
538
1305
  lines.push('');
539
1306
  if (f.pinchPoint) {
@@ -558,8 +1325,20 @@ export function registerAutoInvestigate(server) {
558
1325
  lines.push('## Shared Pinch Points');
559
1326
  lines.push('');
560
1327
  for (const { pp, findingIndices } of sharedPinchPoints) {
1328
+ const ppNode = snapshot.getNodeById(pp.nodeId);
561
1329
  const ppName = truncateNodeName(pp.name, pp.type, pp.selfSize, 50);
562
1330
  lines.push(`- @${pp.nodeId} \`${ppName}\` — self: ${formatBytes(pp.selfSize)}, retains: ${formatBytes(pp.retainedSize)} (${formatNumber(Math.round(pp.ratio))}:1 ratio). Shared by findings ${findingIndices.map(j => `#${j + 1}`).join(', ')}. Freeing this single object would reclaim ${formatBytes(pp.retainedSize)}.`);
1331
+ if (ppNode) {
1332
+ const topProps = [];
1333
+ for (const edge of ppNode.references) {
1334
+ if (edge.type === 'property' && topProps.length < 5) {
1335
+ topProps.push(String(edge.name_or_index));
1336
+ }
1337
+ }
1338
+ if (topProps.length > 0) {
1339
+ lines.push(` Properties: {${topProps.join(', ')}${ppNode.edge_count > 5 ? ', …' : ''}}`);
1340
+ }
1341
+ }
563
1342
  }
564
1343
  lines.push('');
565
1344
  }
@@ -607,6 +1386,187 @@ export function registerAutoInvestigate(server) {
607
1386
  }
608
1387
  lines.push('', '_Use `memlab_shape_histogram` for full shape analysis with retained sizes._');
609
1388
  lines.push('');
1389
+ // Feedback #4: Low-cardinality column detection
1390
+ // Feedback #8: High-cost unique property detection
1391
+ // Feedback round 3 §1b: same-object duplication detection
1392
+ // For large shapes (>10K instances), sample property value cardinality
1393
+ const columnAlerts = [];
1394
+ const duplicationAlerts = [];
1395
+ const dupShapesSeen = new Set();
1396
+ // Pre-bucket: collect qualifying shape keys, then single-pass scan
1397
+ const qualifyingShapes = shapes.filter(s => {
1398
+ if (s.count < 10_000)
1399
+ return false;
1400
+ return !!snapshot.getNodeById(s.exampleNodeId);
1401
+ });
1402
+ const shapeBuckets = new Map();
1403
+ for (const s of qualifyingShapes) {
1404
+ shapeBuckets.set(s.properties.join(','), []);
1405
+ }
1406
+ if (shapeBuckets.size > 0) {
1407
+ const maxSamples = 200;
1408
+ snapshot.nodes.forEach(node => {
1409
+ if (node.type !== 'object' || node.id <= 3)
1410
+ return;
1411
+ if (node.name !== 'Object')
1412
+ return;
1413
+ const props = [];
1414
+ for (const edge of node.references) {
1415
+ if (edge.type === 'property') {
1416
+ props.push(String(edge.name_or_index));
1417
+ }
1418
+ }
1419
+ props.sort();
1420
+ const key = props.join(',');
1421
+ const bucket = shapeBuckets.get(key);
1422
+ if (bucket && bucket.length < maxSamples) {
1423
+ bucket.push(node);
1424
+ }
1425
+ });
1426
+ }
1427
+ for (const s of qualifyingShapes) {
1428
+ const sampleNodes = shapeBuckets.get(s.properties.join(',')) ?? [];
1429
+ if (sampleNodes.length < 50)
1430
+ continue;
1431
+ const sampleCount = sampleNodes.length;
1432
+ const shapeKey = s.properties.join(',');
1433
+ const propStats = [];
1434
+ for (const propName of s.properties) {
1435
+ // Skip prototype / internal pseudo-properties — they are not data
1436
+ // columns, so "pre-filter / intern this column" advice is noise
1437
+ // (Feedback round 4 §2).
1438
+ if (propName === '__proto__' || propName.startsWith('<')) {
1439
+ continue;
1440
+ }
1441
+ const values = new Set();
1442
+ let totalValueSize = 0;
1443
+ let stringValueCount = 0;
1444
+ let allUnique = true;
1445
+ for (const node of sampleNodes) {
1446
+ for (const edge of node.references) {
1447
+ if (edge.type === 'property' &&
1448
+ String(edge.name_or_index) === propName) {
1449
+ const target = edge.toNode;
1450
+ let val = target.name;
1451
+ if (target.isString) {
1452
+ const strNode = target.toStringNode();
1453
+ if (strNode)
1454
+ val = strNode.stringValue;
1455
+ stringValueCount++;
1456
+ totalValueSize += target.self_size;
1457
+ }
1458
+ if (values.has(val))
1459
+ allUnique = false;
1460
+ values.add(val);
1461
+ break;
1462
+ }
1463
+ }
1464
+ }
1465
+ propStats.push({
1466
+ name: propName,
1467
+ cardinality: values.size,
1468
+ stringValueCount,
1469
+ totalValueSize,
1470
+ allUnique,
1471
+ });
1472
+ }
1473
+ // Same-object duplication (§1b), copy-factor corrected (round 4 §1a):
1474
+ // derive the factor from the MOST-DISTINCT string-valued field — the
1475
+ // record's true discriminator — NOT from an arbitrary unique-looking
1476
+ // field. The old code did `sampleCount / cardinality` on any
1477
+ // `*_id`-shaped key, so a CONSTANT field (e.g. `gala_project_id`
1478
+ // with 1 value) reported "~200 copies" when the real duplication was
1479
+ // 2×. If even the most-distinct field repeats, the whole record is
1480
+ // duplicated; if the most-distinct field is ~unique, there is no
1481
+ // duplication regardless of how many constant columns exist.
1482
+ const stringStats = propStats.filter(p => p.stringValueCount >= sampleCount * 0.5);
1483
+ let maxCard = 0;
1484
+ let discrim = '';
1485
+ for (const p of stringStats) {
1486
+ if (p.cardinality > maxCard) {
1487
+ maxCard = p.cardinality;
1488
+ discrim = p.name;
1489
+ }
1490
+ }
1491
+ if (stringStats.length > 0 &&
1492
+ maxCard >= 1 &&
1493
+ maxCard < sampleCount * 0.6 &&
1494
+ sampleCount >= 100 &&
1495
+ !dupShapesSeen.has(shapeKey)) {
1496
+ const copyFactor = Math.round(sampleCount / maxCard);
1497
+ if (copyFactor >= 2) {
1498
+ dupShapesSeen.add(shapeKey);
1499
+ duplicationAlerts.push(`- \`{${s.properties.slice(0, 4).join(', ')}${s.properties.length > 4 ? ', …' : ''}}\` (${formatNumber(s.count)} total instances): even the most-distinct field \`${discrim}\` has only **${formatNumber(maxCard)} distinct value(s)** across ${formatNumber(sampleCount)} sampled — the same records appear to be retained **≈${copyFactor}×** (sample-based estimate; confirm the exact factor with \`memlab_property_distribution\` on \`${discrim}\`, or a content-hash count). Investigate **retention/concurrency** (e.g. many concurrent requests each holding a full copy of this dataset, or the same listing materialized twice), NOT string interning.`);
1500
+ }
1501
+ }
1502
+ // Pass 2: per-property low-cardinality + high-cost-unique notes.
1503
+ for (const p of propStats) {
1504
+ const uniqueKey = looksUniqueKey(p.name);
1505
+ if (!uniqueKey && p.cardinality <= 20 && sampleCount >= 100) {
1506
+ // Genuine low-cardinality column. Only suggest string interning
1507
+ // when the column actually holds strings — interning a numeric
1508
+ // (SMI/heap-number) or boolean column is meaningless
1509
+ // (Feedback round 4 §2).
1510
+ const isStringColumn = p.stringValueCount >= sampleCount * 0.5;
1511
+ columnAlerts.push(isStringColumn
1512
+ ? `- Property \`${p.name}\` has only **${p.cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source or string interning`
1513
+ : `- Property \`${p.name}\` has only **${p.cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source`);
1514
+ }
1515
+ // High-cost unique: all values unique, string, large average size.
1516
+ if (p.allUnique &&
1517
+ p.stringValueCount > sampleCount * 0.8 &&
1518
+ p.cardinality > sampleCount * 0.9) {
1519
+ const avgSize = p.totalValueSize / p.stringValueCount;
1520
+ if (avgSize > 50 && s.count > 10_000) {
1521
+ const estimatedWaste = avgSize * s.count;
1522
+ columnAlerts.push(`- Property \`${p.name}\` has **all unique string values** (avg ${Math.round(avgSize)}B each × ${formatNumber(s.count)} instances = ~${formatBytes(estimatedWaste)}) — verify this field is needed by consumers`);
1523
+ }
1524
+ }
1525
+ }
1526
+ }
1527
+ if (duplicationAlerts.length > 0) {
1528
+ lines.push('## Same-Object Duplication');
1529
+ lines.push('');
1530
+ lines.push('Unique-looking keys with far fewer distinct values than instances — the same records appear to be retained multiple times:');
1531
+ lines.push('');
1532
+ lines.push(...duplicationAlerts.slice(0, 8));
1533
+ lines.push('', '_This is a retention/concurrency issue, **not** an interning opportunity: interning a per-record copy cannot reclaim duplicated whole records. Trace one example with `memlab_retainer_summary` / `memlab_trace_dominators` to find why N copies are kept alive (e.g. unbounded request concurrency, a cache keyed per-request, or fan-out without back-pressure)._');
1534
+ lines.push('');
1535
+ }
1536
+ if (columnAlerts.length > 0) {
1537
+ // Cap to the most relevant lines — on wide shapes this section
1538
+ // used to emit ~100 near-identical lines (Feedback §4).
1539
+ const COLUMN_ALERT_CAP = 12;
1540
+ lines.push('## Data Column Analysis');
1541
+ lines.push('');
1542
+ lines.push('Property value analysis across large object shapes:');
1543
+ lines.push('');
1544
+ lines.push(...columnAlerts.slice(0, COLUMN_ALERT_CAP));
1545
+ if (columnAlerts.length > COLUMN_ALERT_CAP) {
1546
+ lines.push(`- … and ${columnAlerts.length - COLUMN_ALERT_CAP} more column(s). Use \`memlab_property_distribution\` to drill into a specific property.`);
1547
+ }
1548
+ lines.push('', '_Low-cardinality columns can often be filtered at the data source (SQL WHERE clause). High-cost unique string properties should be verified as needed by consumers._');
1549
+ lines.push('');
1550
+ }
1551
+ }
1552
+ if (distributed.length > 0) {
1553
+ lines.push(`## Distributed Accumulation`);
1554
+ lines.push('');
1555
+ lines.push('Classes with many small instances that collectively consume significant memory. No single instance stands out, but the total is substantial:');
1556
+ lines.push('');
1557
+ for (const d of distributed) {
1558
+ const pct = totalSize > 0
1559
+ ? ` (${((d.totalSelfSize / totalSize) * 100).toFixed(1)}% of heap)`
1560
+ : '';
1561
+ const sevIcon = d.totalSelfSize >= totalSize * 0.1
1562
+ ? '🔴'
1563
+ : d.totalSelfSize >= totalSize * 0.05
1564
+ ? '🟠'
1565
+ : '🟡';
1566
+ lines.push(`- ${sevIcon} \`${d.className}\` (${d.nodeType}) — **${formatNumber(d.count)}** instances × ${Math.round(d.avgSize)}B avg = **${formatBytes(d.totalSelfSize)}**${pct} (example: @${d.exampleNodeId})`);
1567
+ }
1568
+ lines.push('', '_These won\'t appear in "top retained objects" because no single instance retains enough. Reduce instance count at the source (batch, deduplicate, or filter before storing)._');
1569
+ lines.push('');
610
1570
  }
611
1571
  if (subscriptions.length > 0) {
612
1572
  lines.push(`## Subscription/Listener Accumulation`);
@@ -615,17 +1575,39 @@ export function registerAutoInvestigate(server) {
615
1575
  lines.push('');
616
1576
  for (const s of subscriptions.slice(0, 5)) {
617
1577
  const propsDisplay = `{${s.properties.join(', ')}}`;
618
- const subSev = classifySeverity(s.totalSelfSize, totalSize);
619
- const subSevIcon = subSev === 'CRITICAL'
1578
+ const baseSev = classifySeverity(s.totalSelfSize, totalSize);
1579
+ const effectiveSev = s.fanOut
1580
+ ? s.fanOut.escalatedSeverity
1581
+ : baseSev;
1582
+ const subSevIcon = effectiveSev === 'CRITICAL'
620
1583
  ? '🔴'
621
- : subSev === 'HIGH'
1584
+ : effectiveSev === 'HIGH'
622
1585
  ? '🟠'
623
- : subSev === 'MEDIUM'
1586
+ : effectiveSev === 'MEDIUM'
624
1587
  ? '🟡'
625
1588
  : '🔵';
626
- lines.push(`- ${subSevIcon} [${subSev}] ${propsDisplay} — **${formatNumber(s.count)}** instances, ${formatBytes(s.totalSelfSize)} self (example: @${s.exampleNodeId})`);
1589
+ lines.push(`- ${subSevIcon} [${effectiveSev}] ${propsDisplay} — **${formatNumber(s.count)}** instances, ${formatBytes(s.totalSelfSize)} self (example: @${s.exampleNodeId})`);
1590
+ if (s.fanOut) {
1591
+ const fo = s.fanOut;
1592
+ lines.push(` - **Listener fan-out detected:** ${formatNumber(fo.uniqueCallbacks)} unique callback(s), ${formatNumber(fo.uniqueContexts)} unique context(s)`);
1593
+ if (fo.orphanedContexts > 0) {
1594
+ lines.push(` - **${formatNumber(fo.orphanedContexts)} orphaned context(s)** — only retained by listener registrations`);
1595
+ }
1596
+ if (fo.contextShapes.length > 0) {
1597
+ const shapeStr = fo.contextShapes
1598
+ .map(cs => `${cs.count}× ${cs.name}`)
1599
+ .join(', ');
1600
+ lines.push(` - Context shapes: ${shapeStr}`);
1601
+ }
1602
+ if (effectiveSev !== baseSev) {
1603
+ lines.push(` - _Severity escalated from ${baseSev} → ${effectiveSev} due to listener fan-out pattern (few callbacks, many orphaned contexts)_`);
1604
+ }
1605
+ }
627
1606
  }
628
- lines.push('', '_These may be event listeners, signal subscriptions, or observer registrations that are not being cleaned up. Use `memlab_retainer_summary` with node_ids from the examples to trace the retention pattern._');
1607
+ lines.push('');
1608
+ lines.push('_These may be event listeners, signal subscriptions, or observer registrations that are not being cleaned up. ' +
1609
+ 'Use `memlab_event_listener_leaks` for detailed listener analysis or ' +
1610
+ '`memlab_retainer_summary` with node_ids from the examples to trace the retention pattern._');
629
1611
  lines.push('');
630
1612
  }
631
1613
  if (errorAccum.length > 0) {
@@ -642,6 +1624,26 @@ export function registerAutoInvestigate(server) {
642
1624
  lines.push('', '_Repeated identical errors are almost always a bug — a module failing to load, a misconfigured import, or a retry loop that never succeeds._');
643
1625
  lines.push('');
644
1626
  }
1627
+ const asyncAlerts = detectAsyncContextLeaks(findings);
1628
+ const frameworkAlerts = focus === 'all' ? detectFrameworkLeaks(snapshot, totalSize) : [];
1629
+ const closureAlerts = focus === 'all' || focus === 'closures'
1630
+ ? scanOversizedClosures(snapshot, totalSize)
1631
+ : [];
1632
+ const appendOnlyAlerts = focus === 'all' ? scanAppendOnlyRecords(snapshot, totalSize) : [];
1633
+ const allPatternAlerts = [
1634
+ ...frameworkAlerts,
1635
+ ...closureAlerts,
1636
+ ...appendOnlyAlerts,
1637
+ ...asyncAlerts,
1638
+ ];
1639
+ if (allPatternAlerts.length > 0) {
1640
+ lines.push('## Known Leak Patterns');
1641
+ lines.push('');
1642
+ for (const alert of allPatternAlerts) {
1643
+ lines.push(alert);
1644
+ lines.push('');
1645
+ }
1646
+ }
645
1647
  const sourceHints = extractSourceHints(findings.map(f => f.trace));
646
1648
  if (sourceHints.length > 0) {
647
1649
  lines.push('## Source Hints');
@@ -655,23 +1657,25 @@ export function registerAutoInvestigate(server) {
655
1657
  }
656
1658
  lines.push('');
657
1659
  }
658
- lines.push('## Suggested Next Steps');
659
- lines.push('');
660
- let stepNum = 1;
661
- if (findings[0]?.pinchPoint) {
662
- const pp = findings[0].pinchPoint;
663
- lines.push(`${stepNum++}. Inspect pinch point: \`memlab_get_node(${pp.nodeId})\` then \`memlab_dominator_subtree(${pp.nodeId})\``);
664
- }
665
- lines.push(`${stepNum++}. Trace top retainer: \`memlab_trace_dominators(${findings[0].node.id})\` for full dominator chain in one call`);
666
- lines.push(`${stepNum++}. Check for patterns: \`memlab_retainer_summary\` with class name of top objects`);
667
- if (promises.pendingCount > 0 && promises.topPending.length > 0) {
668
- lines.push(`${stepNum++}. Investigate pending promises: \`memlab_trace_dominators(${promises.topPending[0].nodeId})\``);
669
- }
670
- if (caches.length > 0) {
671
- lines.push(`${stepNum++}. Analyze caches: \`memlab_cache_analysis\` for detailed cache inspection`);
672
- }
673
- if (shapes.length > 0) {
674
- lines.push(`${stepNum++}. Explore data shapes: \`memlab_shape_histogram\` for detailed shape clustering`);
1660
+ if (!suggestionsSuppressed()) {
1661
+ lines.push('## Suggested Next Steps');
1662
+ lines.push('');
1663
+ let stepNum = 1;
1664
+ if (findings[0]?.pinchPoint) {
1665
+ const pp = findings[0].pinchPoint;
1666
+ lines.push(`${stepNum++}. Inspect pinch point: \`memlab_get_node(${pp.nodeId})\` then \`memlab_dominator_subtree(${pp.nodeId})\``);
1667
+ }
1668
+ lines.push(`${stepNum++}. Trace top retainer: \`memlab_trace_dominators(${findings[0].node.id})\` for full dominator chain in one call`);
1669
+ lines.push(`${stepNum++}. Check for patterns: \`memlab_retainer_summary\` with class name of top objects`);
1670
+ if (promises.pendingCount > 0 && promises.topPending.length > 0) {
1671
+ lines.push(`${stepNum++}. Investigate pending promises: \`memlab_trace_dominators(${promises.topPending[0].nodeId})\``);
1672
+ }
1673
+ if (caches.length > 0) {
1674
+ lines.push(`${stepNum++}. Analyze caches: \`memlab_cache_analysis\` for detailed cache inspection`);
1675
+ }
1676
+ if (shapes.length > 0) {
1677
+ lines.push(`${stepNum++}. Explore data shapes: \`memlab_shape_histogram\` for detailed shape clustering`);
1678
+ }
675
1679
  }
676
1680
  return toolResult(lines.join('\n'));
677
1681
  }