@memlab/mcp-server 2.3.0 → 2.52.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 (205) hide show
  1. package/README.md +18 -14
  2. package/bin/.eslintrc.js +26 -0
  3. package/bin/memlab-cli.js +313 -0
  4. package/dist/artifact-classes.d.ts +86 -0
  5. package/dist/artifact-classes.d.ts.map +1 -0
  6. package/dist/artifact-classes.js +261 -0
  7. package/dist/artifact-classes.js.map +1 -0
  8. package/dist/capture-mode.d.ts +60 -0
  9. package/dist/capture-mode.d.ts.map +1 -0
  10. package/dist/capture-mode.js +39 -0
  11. package/dist/capture-mode.js.map +1 -0
  12. package/dist/guardrail.d.ts.map +1 -1
  13. package/dist/guardrail.js +13 -0
  14. package/dist/guardrail.js.map +1 -1
  15. package/dist/heap-state.d.ts +96 -2
  16. package/dist/heap-state.d.ts.map +1 -1
  17. package/dist/heap-state.js +197 -2
  18. package/dist/heap-state.js.map +1 -1
  19. package/dist/index.js +41 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/progress.d.ts +25 -0
  22. package/dist/progress.d.ts.map +1 -0
  23. package/dist/progress.js +46 -0
  24. package/dist/progress.js.map +1 -0
  25. package/dist/result-handles.d.ts +11 -0
  26. package/dist/result-handles.d.ts.map +1 -0
  27. package/dist/result-handles.js +77 -0
  28. package/dist/result-handles.js.map +1 -0
  29. package/dist/string-index.d.ts +55 -0
  30. package/dist/string-index.d.ts.map +1 -0
  31. package/dist/string-index.js +91 -0
  32. package/dist/string-index.js.map +1 -0
  33. package/dist/tool-registry.d.ts +42 -0
  34. package/dist/tool-registry.d.ts.map +1 -0
  35. package/dist/tool-registry.js +20 -0
  36. package/dist/tool-registry.js.map +1 -0
  37. package/dist/tools/analyze-run.d.ts +12 -0
  38. package/dist/tools/analyze-run.d.ts.map +1 -0
  39. package/dist/tools/analyze-run.js +343 -0
  40. package/dist/tools/analyze-run.js.map +1 -0
  41. package/dist/tools/app-heap.d.ts +12 -0
  42. package/dist/tools/app-heap.d.ts.map +1 -0
  43. package/dist/tools/app-heap.js +140 -0
  44. package/dist/tools/app-heap.js.map +1 -0
  45. package/dist/tools/auto-investigate.d.ts.map +1 -1
  46. package/dist/tools/auto-investigate.js +91 -49
  47. package/dist/tools/auto-investigate.js.map +1 -1
  48. package/dist/tools/batch.d.ts +12 -0
  49. package/dist/tools/batch.d.ts.map +1 -0
  50. package/dist/tools/batch.js +168 -0
  51. package/dist/tools/batch.js.map +1 -0
  52. package/dist/tools/cache-analysis.d.ts.map +1 -1
  53. package/dist/tools/cache-analysis.js +156 -5
  54. package/dist/tools/cache-analysis.js.map +1 -1
  55. package/dist/tools/class-histogram.d.ts.map +1 -1
  56. package/dist/tools/class-histogram.js +14 -3
  57. package/dist/tools/class-histogram.js.map +1 -1
  58. package/dist/tools/collection-trend.d.ts +55 -0
  59. package/dist/tools/collection-trend.d.ts.map +1 -0
  60. package/dist/tools/collection-trend.js +315 -0
  61. package/dist/tools/collection-trend.js.map +1 -0
  62. package/dist/tools/detached-dom.d.ts +2 -0
  63. package/dist/tools/detached-dom.d.ts.map +1 -1
  64. package/dist/tools/detached-dom.js +291 -20
  65. package/dist/tools/detached-dom.js.map +1 -1
  66. package/dist/tools/dev-artifacts.d.ts +62 -6
  67. package/dist/tools/dev-artifacts.d.ts.map +1 -1
  68. package/dist/tools/dev-artifacts.js +466 -23
  69. package/dist/tools/dev-artifacts.js.map +1 -1
  70. package/dist/tools/diff-snapshots.d.ts.map +1 -1
  71. package/dist/tools/diff-snapshots.js +101 -44
  72. package/dist/tools/diff-snapshots.js.map +1 -1
  73. package/dist/tools/dominator-attribution.d.ts +12 -0
  74. package/dist/tools/dominator-attribution.d.ts.map +1 -0
  75. package/dist/tools/dominator-attribution.js +189 -0
  76. package/dist/tools/dominator-attribution.js.map +1 -0
  77. package/dist/tools/dominator-chain.d.ts +12 -0
  78. package/dist/tools/dominator-chain.d.ts.map +1 -0
  79. package/dist/tools/dominator-chain.js +92 -0
  80. package/dist/tools/dominator-chain.js.map +1 -0
  81. package/dist/tools/duplicate-objects.d.ts +12 -0
  82. package/dist/tools/duplicate-objects.d.ts.map +1 -0
  83. package/dist/tools/duplicate-objects.js +172 -0
  84. package/dist/tools/duplicate-objects.js.map +1 -0
  85. package/dist/tools/duplicated-strings.d.ts.map +1 -1
  86. package/dist/tools/duplicated-strings.js +46 -38
  87. package/dist/tools/duplicated-strings.js.map +1 -1
  88. package/dist/tools/eval.d.ts +1 -0
  89. package/dist/tools/eval.d.ts.map +1 -1
  90. package/dist/tools/eval.js +787 -30
  91. package/dist/tools/eval.js.map +1 -1
  92. package/dist/tools/explain-delta.d.ts +12 -0
  93. package/dist/tools/explain-delta.d.ts.map +1 -0
  94. package/dist/tools/explain-delta.js +170 -0
  95. package/dist/tools/explain-delta.js.map +1 -0
  96. package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
  97. package/dist/tools/find-nodes-by-class.js +112 -13
  98. package/dist/tools/find-nodes-by-class.js.map +1 -1
  99. package/dist/tools/finding-index.d.ts +22 -0
  100. package/dist/tools/finding-index.d.ts.map +1 -0
  101. package/dist/tools/finding-index.js +247 -0
  102. package/dist/tools/finding-index.js.map +1 -0
  103. package/dist/tools/for-each.d.ts.map +1 -1
  104. package/dist/tools/for-each.js +15 -3
  105. package/dist/tools/for-each.js.map +1 -1
  106. package/dist/tools/get-references.d.ts.map +1 -1
  107. package/dist/tools/get-references.js +35 -6
  108. package/dist/tools/get-references.js.map +1 -1
  109. package/dist/tools/get-referrers.d.ts.map +1 -1
  110. package/dist/tools/get-referrers.js +52 -6
  111. package/dist/tools/get-referrers.js.map +1 -1
  112. package/dist/tools/hunt-report.d.ts +12 -0
  113. package/dist/tools/hunt-report.d.ts.map +1 -0
  114. package/dist/tools/hunt-report.js +186 -0
  115. package/dist/tools/hunt-report.js.map +1 -0
  116. package/dist/tools/hypothesis.d.ts +12 -0
  117. package/dist/tools/hypothesis.d.ts.map +1 -0
  118. package/dist/tools/hypothesis.js +221 -0
  119. package/dist/tools/hypothesis.js.map +1 -0
  120. package/dist/tools/intern-opportunities.d.ts.map +1 -1
  121. package/dist/tools/intern-opportunities.js +721 -75
  122. package/dist/tools/intern-opportunities.js.map +1 -1
  123. package/dist/tools/ladder.d.ts +33 -0
  124. package/dist/tools/ladder.d.ts.map +1 -0
  125. package/dist/tools/ladder.js +266 -0
  126. package/dist/tools/ladder.js.map +1 -0
  127. package/dist/tools/largest-objects.d.ts.map +1 -1
  128. package/dist/tools/largest-objects.js +43 -16
  129. package/dist/tools/largest-objects.js.map +1 -1
  130. package/dist/tools/leak-report.d.ts +12 -0
  131. package/dist/tools/leak-report.d.ts.map +1 -0
  132. package/dist/tools/leak-report.js +256 -0
  133. package/dist/tools/leak-report.js.map +1 -0
  134. package/dist/tools/load-snapshot.d.ts +78 -0
  135. package/dist/tools/load-snapshot.d.ts.map +1 -1
  136. package/dist/tools/load-snapshot.js +519 -29
  137. package/dist/tools/load-snapshot.js.map +1 -1
  138. package/dist/tools/map-entries.d.ts +12 -0
  139. package/dist/tools/map-entries.d.ts.map +1 -0
  140. package/dist/tools/map-entries.js +139 -0
  141. package/dist/tools/map-entries.js.map +1 -0
  142. package/dist/tools/match-object.d.ts +12 -0
  143. package/dist/tools/match-object.d.ts.map +1 -0
  144. package/dist/tools/match-object.js +165 -0
  145. package/dist/tools/match-object.js.map +1 -0
  146. package/dist/tools/object-shape.d.ts.map +1 -1
  147. package/dist/tools/object-shape.js +92 -30
  148. package/dist/tools/object-shape.js.map +1 -1
  149. package/dist/tools/population-diff.d.ts +57 -0
  150. package/dist/tools/population-diff.d.ts.map +1 -0
  151. package/dist/tools/population-diff.js +338 -0
  152. package/dist/tools/population-diff.js.map +1 -0
  153. package/dist/tools/quick-diagnosis.d.ts.map +1 -1
  154. package/dist/tools/quick-diagnosis.js +12 -4
  155. package/dist/tools/quick-diagnosis.js.map +1 -1
  156. package/dist/tools/retainer-layers.d.ts +23 -0
  157. package/dist/tools/retainer-layers.d.ts.map +1 -0
  158. package/dist/tools/retainer-layers.js +179 -0
  159. package/dist/tools/retainer-layers.js.map +1 -0
  160. package/dist/tools/retainer-summary.d.ts +1 -0
  161. package/dist/tools/retainer-summary.d.ts.map +1 -1
  162. package/dist/tools/retainer-summary.js +128 -32
  163. package/dist/tools/retainer-summary.js.map +1 -1
  164. package/dist/tools/retainer-trace.d.ts.map +1 -1
  165. package/dist/tools/retainer-trace.js +105 -25
  166. package/dist/tools/retainer-trace.js.map +1 -1
  167. package/dist/tools/search-nodes.d.ts.map +1 -1
  168. package/dist/tools/search-nodes.js +5 -1
  169. package/dist/tools/search-nodes.js.map +1 -1
  170. package/dist/tools/search-strings.d.ts.map +1 -1
  171. package/dist/tools/search-strings.js +53 -7
  172. package/dist/tools/search-strings.js.map +1 -1
  173. package/dist/tools/sequence-analysis.d.ts +49 -0
  174. package/dist/tools/sequence-analysis.d.ts.map +1 -1
  175. package/dist/tools/sequence-analysis.js +268 -78
  176. package/dist/tools/sequence-analysis.js.map +1 -1
  177. package/dist/tools/shape-histogram.d.ts.map +1 -1
  178. package/dist/tools/shape-histogram.js +9 -2
  179. package/dist/tools/shape-histogram.js.map +1 -1
  180. package/dist/tools/sliced-strings.d.ts.map +1 -1
  181. package/dist/tools/sliced-strings.js +22 -4
  182. package/dist/tools/sliced-strings.js.map +1 -1
  183. package/dist/tools/snapshot-header.d.ts +28 -0
  184. package/dist/tools/snapshot-header.d.ts.map +1 -0
  185. package/dist/tools/snapshot-header.js +111 -0
  186. package/dist/tools/snapshot-header.js.map +1 -0
  187. package/dist/tools/string-patterns.d.ts.map +1 -1
  188. package/dist/tools/string-patterns.js +5 -4
  189. package/dist/tools/string-patterns.js.map +1 -1
  190. package/dist/tools/tools-index.d.ts +12 -0
  191. package/dist/tools/tools-index.d.ts.map +1 -0
  192. package/dist/tools/tools-index.js +342 -0
  193. package/dist/tools/tools-index.js.map +1 -0
  194. package/dist/tools/verify-fix.d.ts +23 -0
  195. package/dist/tools/verify-fix.d.ts.map +1 -0
  196. package/dist/tools/verify-fix.js +211 -0
  197. package/dist/tools/verify-fix.js.map +1 -0
  198. package/dist/tools/weakmap-entries.d.ts.map +1 -1
  199. package/dist/tools/weakmap-entries.js +6 -1
  200. package/dist/tools/weakmap-entries.js.map +1 -1
  201. package/dist/utils.d.ts +204 -1
  202. package/dist/utils.d.ts.map +1 -1
  203. package/dist/utils.js +478 -8
  204. package/dist/utils.js.map +1 -1
  205. package/package.json +5 -4
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @format
8
+ * @oncall memory_lab
9
+ */
10
+ import { z } from 'zod';
11
+ import { getSnapshot, getSnapshotMetadata } from '../heap-state.js';
12
+ import { formatBytes, formatNumber, markdownTable, errorResult, toolResult, } from '../utils.js';
13
+ import { classifyArtifact } from '../artifact-classes.js';
14
+ import { collectDevRoots, computeReachableWithoutDevRoots, } from './dev-artifacts.js';
15
+ /**
16
+ * How much of this heap is actually the APPLICATION?
17
+ *
18
+ * A dev-build heap is mostly not the app. Measured on one WhatsApp Web capture:
19
+ * `system / ExternalStringData` — the bundle's own source text — was 56% of a
20
+ * 311 MB heap, single strings in it retained 84.9 / 22.5 / 17.8 / 10.2 MB, and
21
+ * code-related memory came to roughly two thirds of the total. So "drive to
22
+ * 600 MB" is mostly an instruction to load more bundle, the number is not
23
+ * comparable to production, and a threshold expressed against it is measuring
24
+ * the wrong thing.
25
+ *
26
+ * The split below is deliberately coarse and named, not a single magic number:
27
+ * a reader has to be able to see WHICH bucket dominates, because that is what
28
+ * decides whether the capture is usable at all.
29
+ */
30
+ const CODE_TYPES = new Set(['code']);
31
+ // Bundle source text and the V8 structures that hold it. These exist because
32
+ // the app was *loaded*, not because it did anything.
33
+ const BUNDLE_NAME_RE = /ExternalStringData|InstructionStream|BytecodeArray|SharedFunctionInfo|ScopeInfo|UncompiledData|FeedbackVector|FeedbackMetadata|FeedbackCell|ClosureFeedbackCellArray|\(constant pool\)|ConstantPool/;
34
+ export function registerAppHeap(server) {
35
+ server.tool('memlab_app_heap', 'Report APP-ATTRIBUTABLE heap: total self size minus bundle/code memory, minus dev-build artifacts, minus measurement-harness content — the part of the heap that is actually the application. ' +
36
+ 'On a dev build the raw number is mostly not the app: a measured WhatsApp Web capture had bundle source text (`system / ExternalStringData`) at 56% of a 311 MB heap and code-related memory at roughly two thirds of the total. A leak-hunt threshold expressed against the raw total therefore mostly measures how much bundle has loaded, and is not comparable to production at all. ' +
37
+ 'Use this as the number to drive a hunt against and to quote in a report; use memlab_leak_report for whether anything is actually growing.', {
38
+ show_breakdown: z
39
+ .boolean()
40
+ .optional()
41
+ .default(true)
42
+ .describe('Show the per-bucket table (default true). The breakdown is the point — which bucket dominates decides whether the capture is usable.'),
43
+ }, async ({ show_breakdown }) => {
44
+ try {
45
+ const snapshot = getSnapshot();
46
+ const meta = getSnapshotMetadata();
47
+ const devRoots = collectDevRoots(snapshot);
48
+ const reached = devRoots.byId.size > 0
49
+ ? computeReachableWithoutDevRoots(snapshot, devRoots)
50
+ : null;
51
+ let total = 0;
52
+ let code = 0;
53
+ let bundle = 0;
54
+ let devOnly = 0;
55
+ let artifact = 0;
56
+ let app = 0;
57
+ let appNodes = 0;
58
+ const isDevOnly = (node) => reached != null && reached[node.nodeIndex] === 0;
59
+ snapshot.nodes.forEach(node => {
60
+ if (node.id <= 3)
61
+ return;
62
+ const size = node.self_size;
63
+ total += size;
64
+ // Precedence matters: a node is counted once, in the most specific
65
+ // bucket that explains why it is NOT app memory.
66
+ if (isDevOnly(node)) {
67
+ devOnly += size;
68
+ return;
69
+ }
70
+ if (CODE_TYPES.has(node.type)) {
71
+ code += size;
72
+ return;
73
+ }
74
+ if (BUNDLE_NAME_RE.test(node.name)) {
75
+ bundle += size;
76
+ return;
77
+ }
78
+ if (classifyArtifact(node.name) != null) {
79
+ artifact += size;
80
+ return;
81
+ }
82
+ app += size;
83
+ appNodes++;
84
+ });
85
+ const pct = (n) => total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '—';
86
+ const lines = [
87
+ '## App-attributable heap',
88
+ '',
89
+ `**${formatBytes(app)} of ${formatBytes(total)} (${pct(app)}) is application memory** across ${formatNumber(appNodes)} nodes` +
90
+ (meta ? ` in \`${meta.fileName}\`` : '') +
91
+ '.',
92
+ '',
93
+ ];
94
+ if (show_breakdown) {
95
+ lines.push(markdownTable(['Bucket', 'Self size', '% of heap', 'What it is'], [
96
+ [
97
+ 'App',
98
+ formatBytes(app),
99
+ pct(app),
100
+ 'The number to drive and report',
101
+ ],
102
+ [
103
+ 'Bundle / source text',
104
+ formatBytes(bundle),
105
+ pct(bundle),
106
+ 'Loaded, not allocated by use; absent at this scale in prod',
107
+ ],
108
+ [
109
+ 'Code / bytecode',
110
+ formatBytes(code),
111
+ pct(code),
112
+ 'JIT + compiled code; grows by exercising new paths',
113
+ ],
114
+ [
115
+ 'Dev/automation-retained',
116
+ formatBytes(devOnly),
117
+ pct(devOnly),
118
+ 'Held only via inspector/dev roots; GC-eligible in prod',
119
+ ],
120
+ [
121
+ 'Known artifacts',
122
+ formatBytes(artifact),
123
+ pct(artifact),
124
+ 'CDP/a11y/warmup families',
125
+ ],
126
+ ], new Set([1, 2])), '');
127
+ }
128
+ const nonApp = total - app;
129
+ if (total > 0 && nonApp / total > 0.5) {
130
+ lines.push(`> ⚠️ **${pct(nonApp)} of this heap is not the application.** A threshold expressed against the raw total is mostly measuring how much bundle and code has loaded. Drive against the app-attributable figure above, and do not compare the raw total to production.`);
131
+ }
132
+ lines.push('', '_Buckets are mutually exclusive and sum to the total; each node lands in the most specific bucket that explains why it is not app memory. Self size, not retained: retained sizes overlap and would not sum._');
133
+ return toolResult(lines.join('\n'));
134
+ }
135
+ catch (err) {
136
+ return errorResult(err);
137
+ }
138
+ });
139
+ }
140
+ //# sourceMappingURL=app-heap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-heap.js","sourceRoot":"","sources":["../../src/tools/app-heap.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAAC,WAAW,EAAE,mBAAmB,EAAC,MAAM,kBAAkB,CAAC;AAClE,OAAO,EACL,WAAW,EACX,YAAY,EACZ,aAAa,EACb,WAAW,EACX,UAAU,GACX,MAAM,aAAa,CAAC;AACrB,OAAO,EAAC,gBAAgB,EAAC,MAAM,wBAAwB,CAAC;AACxD,OAAO,EACL,eAAe,EACf,+BAA+B,GAChC,MAAM,oBAAoB,CAAC;AAE5B;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,GAAwB,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;AAE1D,6EAA6E;AAC7E,qDAAqD;AACrD,MAAM,cAAc,GAClB,qMAAqM,CAAC;AAExM,MAAM,UAAU,eAAe,CAAC,MAAiB;IAC/C,MAAM,CAAC,IAAI,CACT,iBAAiB,EACjB,gMAAgM;QAC9L,0XAA0X;QAC1X,2IAA2I,EAC7I;QACE,cAAc,EAAE,CAAC;aACd,OAAO,EAAE;aACT,QAAQ,EAAE;aACV,OAAO,CAAC,IAAI,CAAC;aACb,QAAQ,CACP,sIAAsI,CACvI;KACJ,EACD,KAAK,EAAE,EAAC,cAAc,EAAC,EAAE,EAAE;QACzB,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAC;YAC/B,MAAM,IAAI,GAAG,mBAAmB,EAAE,CAAC;YAEnC,MAAM,QAAQ,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAC;YAC3C,MAAM,OAAO,GACX,QAAQ,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC;gBACpB,CAAC,CAAC,+BAA+B,CAAC,QAAQ,EAAE,QAAQ,CAAC;gBACrD,CAAC,CAAC,IAAI,CAAC;YAEX,IAAI,KAAK,GAAG,CAAC,CAAC;YACd,IAAI,IAAI,GAAG,CAAC,CAAC;YACb,IAAI,MAAM,GAAG,CAAC,CAAC;YACf,IAAI,OAAO,GAAG,CAAC,CAAC;YAChB,IAAI,QAAQ,GAAG,CAAC,CAAC;YACjB,IAAI,GAAG,GAAG,CAAC,CAAC;YACZ,IAAI,QAAQ,GAAG,CAAC,CAAC;YAEjB,MAAM,SAAS,GAAG,CAAC,IAAe,EAAW,EAAE,CAC7C,OAAO,IAAI,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;YAEnD,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE;gBAC5B,IAAI,IAAI,CAAC,EAAE,IAAI,CAAC;oBAAE,OAAO;gBACzB,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC;gBAC5B,KAAK,IAAI,IAAI,CAAC;gBACd,mEAAmE;gBACnE,iDAAiD;gBACjD,IAAI,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC;oBACpB,OAAO,IAAI,IAAI,CAAC;oBAChB,OAAO;gBACT,CAAC;gBACD,IAAI,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;oBAC9B,IAAI,IAAI,IAAI,CAAC;oBACb,OAAO;gBACT,CAAC;gBACD,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;oBACnC,MAAM,IAAI,IAAI,CAAC;oBACf,OAAO;gBACT,CAAC;gBACD,IAAI,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC;oBACxC,QAAQ,IAAI,IAAI,CAAC;oBACjB,OAAO;gBACT,CAAC;gBACD,GAAG,IAAI,IAAI,CAAC;gBACZ,QAAQ,EAAE,CAAC;YACb,CAAC,CAAC,CAAC;YAEH,MAAM,GAAG,GAAG,CAAC,CAAS,EAAU,EAAE,CAChC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;YAEzD,MAAM,KAAK,GAAa;gBACtB,0BAA0B;gBAC1B,EAAE;gBACF,KAAK,WAAW,CAAC,GAAG,CAAC,OAAO,WAAW,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,GAAG,CAAC,oCAAoC,YAAY,CAAC,QAAQ,CAAC,QAAQ;oBAC3H,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;oBACxC,GAAG;gBACL,EAAE;aACH,CAAC;YAEF,IAAI,cAAc,EAAE,CAAC;gBACnB,KAAK,CAAC,IAAI,CACR,aAAa,CACX,CAAC,QAAQ,EAAE,WAAW,EAAE,WAAW,EAAE,YAAY,CAAC,EAClD;oBACE;wBACE,KAAK;wBACL,WAAW,CAAC,GAAG,CAAC;wBAChB,GAAG,CAAC,GAAG,CAAC;wBACR,gCAAgC;qBACjC;oBACD;wBACE,sBAAsB;wBACtB,WAAW,CAAC,MAAM,CAAC;wBACnB,GAAG,CAAC,MAAM,CAAC;wBACX,4DAA4D;qBAC7D;oBACD;wBACE,iBAAiB;wBACjB,WAAW,CAAC,IAAI,CAAC;wBACjB,GAAG,CAAC,IAAI,CAAC;wBACT,oDAAoD;qBACrD;oBACD;wBACE,yBAAyB;wBACzB,WAAW,CAAC,OAAO,CAAC;wBACpB,GAAG,CAAC,OAAO,CAAC;wBACZ,wDAAwD;qBACzD;oBACD;wBACE,iBAAiB;wBACjB,WAAW,CAAC,QAAQ,CAAC;wBACrB,GAAG,CAAC,QAAQ,CAAC;wBACb,0BAA0B;qBAC3B;iBACF,EACD,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAChB,EACD,EAAE,CACH,CAAC;YACJ,CAAC;YAED,MAAM,MAAM,GAAG,KAAK,GAAG,GAAG,CAAC;YAC3B,IAAI,KAAK,GAAG,CAAC,IAAI,MAAM,GAAG,KAAK,GAAG,GAAG,EAAE,CAAC;gBACtC,KAAK,CAAC,IAAI,CACR,UAAU,GAAG,CAAC,MAAM,CAAC,+OAA+O,CACrQ,CAAC;YACJ,CAAC;YACD,KAAK,CAAC,IAAI,CACR,EAAE,EACF,+MAA+M,CAChN,CAAC;YACF,OAAO,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QACtC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,WAAW,CAAC,GAAG,CAAC,CAAC;QAC1B,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"auto-investigate.d.ts","sourceRoot":"","sources":["../../src/tools/auto-investigate.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAC;AAm9CvE,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAirB/D"}
1
+ {"version":3,"file":"auto-investigate.d.ts","sourceRoot":"","sources":["../../src/tools/auto-investigate.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAC;AA49CvE,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA0tB/D"}
@@ -11,7 +11,7 @@ import memlabCore from '@memlab/core';
11
11
  const { utils, NumericSet } = memlabCore;
12
12
  import { z } from 'zod';
13
13
  import { getSnapshot, getSnapshotMetadata } from '../heap-state.js';
14
- import { filterLargestObjects, isNodeWorthInspecting, formatBytes, formatNumber, truncateNodeName, instrumentationRetainerNote, errorResult, toolResult, suggestionsSuppressed, } from '../utils.js';
14
+ import { filterLargestObjects, isNodeWorthInspecting, formatBytes, formatNumber, formatRetainerTree, truncateNodeName, instrumentationRetainerNote, errorResult, toolResult, suggestionsSuppressed, } from '../utils.js';
15
15
  function classifySeverity(retainedSize, totalSize) {
16
16
  if (totalSize === 0)
17
17
  return 'LOW';
@@ -598,22 +598,31 @@ function formatTrace(trace, maxSteps) {
598
598
  continue;
599
599
  collapsed.push(step);
600
600
  }
601
- const shown = collapsed.length <= maxSteps
602
- ? collapsed
603
- : [...collapsed.slice(0, 3), ...collapsed.slice(-2)];
604
- const parts = [];
605
- for (let i = 0; i < shown.length; i++) {
606
- const s = shown[i];
607
- const name = truncateNodeName(s.name, s.type, s.selfSize, 50);
608
- parts.push(`@${s.nodeId} ${name} (${s.type}) [${formatBytes(s.retainedSize)}]`);
609
- if (i === 2 && collapsed.length > maxSteps) {
610
- parts.push(` … ${collapsed.length - 5} more nodes …`);
611
- }
612
- if (i < shown.length - 1 && s.edgeName != null) {
613
- parts[parts.length - 1] += ` --${s.edgeName}-->`;
614
- }
615
- }
616
- return parts.join('\n → ');
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 });
617
626
  }
618
627
  function computeTraceOverlap(a, b) {
619
628
  let prefixLen = 0;
@@ -1265,7 +1274,7 @@ export function registerAutoInvestigate(server) {
1265
1274
  lines.push(`**Retainer chain:** (same chain as #${traceMatch.matchedIndex + 1}) → [${divergeEdge}] ${divergeName} — ${formatBytes(f.node.retainedSize)}`);
1266
1275
  }
1267
1276
  else {
1268
- lines.push(`**Retainer chain:** ${formatTrace(f.trace, 8)}`);
1277
+ lines.push(`**Retainer chain:**\n${formatTrace(f.trace, 8)}`);
1269
1278
  }
1270
1279
  lines.push('');
1271
1280
  lines.push(`**Alternate retainers of the same subtree** (retained sizes overlap — not additive): @${f.node.id} (${formatBytes(f.node.retainedSize)})` +
@@ -1286,7 +1295,7 @@ export function registerAutoInvestigate(server) {
1286
1295
  else {
1287
1296
  lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] @${f.node.id} \`${name}\` (${f.node.type}) — ${formatBytes(f.node.retainedSize)}${pct}`);
1288
1297
  lines.push('');
1289
- lines.push(`**Retainer chain:** ${formatTrace(f.trace, 8)}`);
1298
+ lines.push(`**Retainer chain:**\n${formatTrace(f.trace, 8)}`);
1290
1299
  }
1291
1300
  previousTraces.push(f.trace);
1292
1301
  const instrNote = instrumentationRetainerNote(f.trace);
@@ -1419,8 +1428,16 @@ export function registerAutoInvestigate(server) {
1419
1428
  const sampleNodes = shapeBuckets.get(s.properties.join(',')) ?? [];
1420
1429
  if (sampleNodes.length < 50)
1421
1430
  continue;
1422
- // Analyze each property's cardinality and value characteristics
1431
+ const sampleCount = sampleNodes.length;
1432
+ const shapeKey = s.properties.join(',');
1433
+ const propStats = [];
1423
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
+ }
1424
1441
  const values = new Set();
1425
1442
  let totalValueSize = 0;
1426
1443
  let stringValueCount = 0;
@@ -1445,39 +1462,64 @@ export function registerAutoInvestigate(server) {
1445
1462
  }
1446
1463
  }
1447
1464
  }
1448
- const cardinality = values.size;
1449
- const sampleCount = sampleNodes.length;
1450
- const uniqueKey = looksUniqueKey(propName);
1451
- const shapeKey = s.properties.join(',');
1452
- // Same-object duplication vs genuine low cardinality (§1b): a field
1453
- // that *should* be unique per record but has far fewer distinct
1454
- // values than sampled instances means the SAME records are retained
1455
- // N times — a retention/concurrency fix, not interning.
1456
- if (uniqueKey &&
1457
- sampleCount >= 100 &&
1458
- cardinality >= 1 &&
1459
- cardinality < sampleCount * 0.6 &&
1460
- !dupShapesSeen.has(shapeKey)) {
1461
- const dupFactor = Math.round(sampleCount / Math.max(1, cardinality));
1462
- if (dupFactor >= 2) {
1463
- dupShapesSeen.add(shapeKey);
1464
- duplicationAlerts.push(`- \`${propName}\` (a unique-looking key) has only **${formatNumber(cardinality)} distinct value(s)** across ${formatNumber(sampleCount)} sampled of ${formatNumber(s.count)} total \`{${s.properties.slice(0, 4).join(', ')}${s.properties.length > 4 ? ', …' : ''}}\` instances — looks like **~${dupFactor} retained copies of the same records**, not a low-cardinality column. Investigate **retention/concurrency** (e.g. many concurrent requests each holding a full copy of this dataset), NOT string interning.`);
1465
- }
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;
1466
1489
  }
1467
- else if (!uniqueKey &&
1468
- cardinality <= 20 &&
1469
- sampleCount >= 100) {
1470
- // Genuine low-cardinality column.
1471
- columnAlerts.push(`- Property \`${propName}\` has only **${cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source or string interning`);
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`);
1472
1514
  }
1473
- // High-cost unique: all values unique, string, large average size
1474
- if (allUnique &&
1475
- stringValueCount > sampleCount * 0.8 &&
1476
- cardinality > sampleCount * 0.9) {
1477
- const avgSize = totalValueSize / stringValueCount;
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;
1478
1520
  if (avgSize > 50 && s.count > 10_000) {
1479
1521
  const estimatedWaste = avgSize * s.count;
1480
- columnAlerts.push(`- Property \`${propName}\` has **all unique string values** (avg ${Math.round(avgSize)}B each × ${formatNumber(s.count)} instances = ~${formatBytes(estimatedWaste)}) — verify this field is needed by consumers`);
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`);
1481
1523
  }
1482
1524
  }
1483
1525
  }