@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.
- package/README.md +18 -14
- package/bin/.eslintrc.js +26 -0
- package/bin/memlab-cli.js +313 -0
- package/dist/artifact-classes.d.ts +86 -0
- package/dist/artifact-classes.d.ts.map +1 -0
- package/dist/artifact-classes.js +261 -0
- package/dist/artifact-classes.js.map +1 -0
- package/dist/capture-mode.d.ts +60 -0
- package/dist/capture-mode.d.ts.map +1 -0
- package/dist/capture-mode.js +39 -0
- package/dist/capture-mode.js.map +1 -0
- package/dist/guardrail.d.ts.map +1 -1
- package/dist/guardrail.js +13 -0
- package/dist/guardrail.js.map +1 -1
- package/dist/heap-state.d.ts +96 -2
- package/dist/heap-state.d.ts.map +1 -1
- package/dist/heap-state.js +197 -2
- package/dist/heap-state.js.map +1 -1
- package/dist/index.js +41 -1
- package/dist/index.js.map +1 -1
- package/dist/progress.d.ts +25 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +46 -0
- package/dist/progress.js.map +1 -0
- package/dist/result-handles.d.ts +11 -0
- package/dist/result-handles.d.ts.map +1 -0
- package/dist/result-handles.js +77 -0
- package/dist/result-handles.js.map +1 -0
- package/dist/string-index.d.ts +55 -0
- package/dist/string-index.d.ts.map +1 -0
- package/dist/string-index.js +91 -0
- package/dist/string-index.js.map +1 -0
- package/dist/tool-registry.d.ts +42 -0
- package/dist/tool-registry.d.ts.map +1 -0
- package/dist/tool-registry.js +20 -0
- package/dist/tool-registry.js.map +1 -0
- package/dist/tools/analyze-run.d.ts +12 -0
- package/dist/tools/analyze-run.d.ts.map +1 -0
- package/dist/tools/analyze-run.js +343 -0
- package/dist/tools/analyze-run.js.map +1 -0
- package/dist/tools/app-heap.d.ts +12 -0
- package/dist/tools/app-heap.d.ts.map +1 -0
- package/dist/tools/app-heap.js +140 -0
- package/dist/tools/app-heap.js.map +1 -0
- package/dist/tools/auto-investigate.d.ts.map +1 -1
- package/dist/tools/auto-investigate.js +91 -49
- package/dist/tools/auto-investigate.js.map +1 -1
- package/dist/tools/batch.d.ts +12 -0
- package/dist/tools/batch.d.ts.map +1 -0
- package/dist/tools/batch.js +168 -0
- package/dist/tools/batch.js.map +1 -0
- package/dist/tools/cache-analysis.d.ts.map +1 -1
- package/dist/tools/cache-analysis.js +156 -5
- package/dist/tools/cache-analysis.js.map +1 -1
- package/dist/tools/class-histogram.d.ts.map +1 -1
- package/dist/tools/class-histogram.js +14 -3
- package/dist/tools/class-histogram.js.map +1 -1
- package/dist/tools/collection-trend.d.ts +55 -0
- package/dist/tools/collection-trend.d.ts.map +1 -0
- package/dist/tools/collection-trend.js +315 -0
- package/dist/tools/collection-trend.js.map +1 -0
- package/dist/tools/detached-dom.d.ts +2 -0
- package/dist/tools/detached-dom.d.ts.map +1 -1
- package/dist/tools/detached-dom.js +291 -20
- package/dist/tools/detached-dom.js.map +1 -1
- package/dist/tools/dev-artifacts.d.ts +62 -6
- package/dist/tools/dev-artifacts.d.ts.map +1 -1
- package/dist/tools/dev-artifacts.js +466 -23
- package/dist/tools/dev-artifacts.js.map +1 -1
- package/dist/tools/diff-snapshots.d.ts.map +1 -1
- package/dist/tools/diff-snapshots.js +101 -44
- package/dist/tools/diff-snapshots.js.map +1 -1
- package/dist/tools/dominator-attribution.d.ts +12 -0
- package/dist/tools/dominator-attribution.d.ts.map +1 -0
- package/dist/tools/dominator-attribution.js +189 -0
- package/dist/tools/dominator-attribution.js.map +1 -0
- package/dist/tools/dominator-chain.d.ts +12 -0
- package/dist/tools/dominator-chain.d.ts.map +1 -0
- package/dist/tools/dominator-chain.js +92 -0
- package/dist/tools/dominator-chain.js.map +1 -0
- package/dist/tools/duplicate-objects.d.ts +12 -0
- package/dist/tools/duplicate-objects.d.ts.map +1 -0
- package/dist/tools/duplicate-objects.js +172 -0
- package/dist/tools/duplicate-objects.js.map +1 -0
- package/dist/tools/duplicated-strings.d.ts.map +1 -1
- package/dist/tools/duplicated-strings.js +46 -38
- package/dist/tools/duplicated-strings.js.map +1 -1
- package/dist/tools/eval.d.ts +1 -0
- package/dist/tools/eval.d.ts.map +1 -1
- package/dist/tools/eval.js +787 -30
- package/dist/tools/eval.js.map +1 -1
- package/dist/tools/explain-delta.d.ts +12 -0
- package/dist/tools/explain-delta.d.ts.map +1 -0
- package/dist/tools/explain-delta.js +170 -0
- package/dist/tools/explain-delta.js.map +1 -0
- package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
- package/dist/tools/find-nodes-by-class.js +112 -13
- package/dist/tools/find-nodes-by-class.js.map +1 -1
- package/dist/tools/finding-index.d.ts +22 -0
- package/dist/tools/finding-index.d.ts.map +1 -0
- package/dist/tools/finding-index.js +247 -0
- package/dist/tools/finding-index.js.map +1 -0
- package/dist/tools/for-each.d.ts.map +1 -1
- package/dist/tools/for-each.js +15 -3
- package/dist/tools/for-each.js.map +1 -1
- package/dist/tools/get-references.d.ts.map +1 -1
- package/dist/tools/get-references.js +35 -6
- package/dist/tools/get-references.js.map +1 -1
- package/dist/tools/get-referrers.d.ts.map +1 -1
- package/dist/tools/get-referrers.js +52 -6
- package/dist/tools/get-referrers.js.map +1 -1
- package/dist/tools/hunt-report.d.ts +12 -0
- package/dist/tools/hunt-report.d.ts.map +1 -0
- package/dist/tools/hunt-report.js +186 -0
- package/dist/tools/hunt-report.js.map +1 -0
- package/dist/tools/hypothesis.d.ts +12 -0
- package/dist/tools/hypothesis.d.ts.map +1 -0
- package/dist/tools/hypothesis.js +221 -0
- package/dist/tools/hypothesis.js.map +1 -0
- package/dist/tools/intern-opportunities.d.ts.map +1 -1
- package/dist/tools/intern-opportunities.js +721 -75
- package/dist/tools/intern-opportunities.js.map +1 -1
- package/dist/tools/ladder.d.ts +33 -0
- package/dist/tools/ladder.d.ts.map +1 -0
- package/dist/tools/ladder.js +266 -0
- package/dist/tools/ladder.js.map +1 -0
- package/dist/tools/largest-objects.d.ts.map +1 -1
- package/dist/tools/largest-objects.js +43 -16
- package/dist/tools/largest-objects.js.map +1 -1
- package/dist/tools/leak-report.d.ts +12 -0
- package/dist/tools/leak-report.d.ts.map +1 -0
- package/dist/tools/leak-report.js +256 -0
- package/dist/tools/leak-report.js.map +1 -0
- package/dist/tools/load-snapshot.d.ts +78 -0
- package/dist/tools/load-snapshot.d.ts.map +1 -1
- package/dist/tools/load-snapshot.js +519 -29
- package/dist/tools/load-snapshot.js.map +1 -1
- package/dist/tools/map-entries.d.ts +12 -0
- package/dist/tools/map-entries.d.ts.map +1 -0
- package/dist/tools/map-entries.js +139 -0
- package/dist/tools/map-entries.js.map +1 -0
- package/dist/tools/match-object.d.ts +12 -0
- package/dist/tools/match-object.d.ts.map +1 -0
- package/dist/tools/match-object.js +165 -0
- package/dist/tools/match-object.js.map +1 -0
- package/dist/tools/object-shape.d.ts.map +1 -1
- package/dist/tools/object-shape.js +92 -30
- package/dist/tools/object-shape.js.map +1 -1
- package/dist/tools/population-diff.d.ts +57 -0
- package/dist/tools/population-diff.d.ts.map +1 -0
- package/dist/tools/population-diff.js +338 -0
- package/dist/tools/population-diff.js.map +1 -0
- package/dist/tools/quick-diagnosis.d.ts.map +1 -1
- package/dist/tools/quick-diagnosis.js +12 -4
- package/dist/tools/quick-diagnosis.js.map +1 -1
- package/dist/tools/retainer-layers.d.ts +23 -0
- package/dist/tools/retainer-layers.d.ts.map +1 -0
- package/dist/tools/retainer-layers.js +179 -0
- package/dist/tools/retainer-layers.js.map +1 -0
- package/dist/tools/retainer-summary.d.ts +1 -0
- package/dist/tools/retainer-summary.d.ts.map +1 -1
- package/dist/tools/retainer-summary.js +128 -32
- package/dist/tools/retainer-summary.js.map +1 -1
- package/dist/tools/retainer-trace.d.ts.map +1 -1
- package/dist/tools/retainer-trace.js +105 -25
- package/dist/tools/retainer-trace.js.map +1 -1
- package/dist/tools/search-nodes.d.ts.map +1 -1
- package/dist/tools/search-nodes.js +5 -1
- package/dist/tools/search-nodes.js.map +1 -1
- package/dist/tools/search-strings.d.ts.map +1 -1
- package/dist/tools/search-strings.js +53 -7
- package/dist/tools/search-strings.js.map +1 -1
- package/dist/tools/sequence-analysis.d.ts +49 -0
- package/dist/tools/sequence-analysis.d.ts.map +1 -1
- package/dist/tools/sequence-analysis.js +268 -78
- package/dist/tools/sequence-analysis.js.map +1 -1
- package/dist/tools/shape-histogram.d.ts.map +1 -1
- package/dist/tools/shape-histogram.js +9 -2
- package/dist/tools/shape-histogram.js.map +1 -1
- package/dist/tools/sliced-strings.d.ts.map +1 -1
- package/dist/tools/sliced-strings.js +22 -4
- package/dist/tools/sliced-strings.js.map +1 -1
- package/dist/tools/snapshot-header.d.ts +28 -0
- package/dist/tools/snapshot-header.d.ts.map +1 -0
- package/dist/tools/snapshot-header.js +111 -0
- package/dist/tools/snapshot-header.js.map +1 -0
- package/dist/tools/string-patterns.d.ts.map +1 -1
- package/dist/tools/string-patterns.js +5 -4
- package/dist/tools/string-patterns.js.map +1 -1
- package/dist/tools/tools-index.d.ts +12 -0
- package/dist/tools/tools-index.d.ts.map +1 -0
- package/dist/tools/tools-index.js +342 -0
- package/dist/tools/tools-index.js.map +1 -0
- package/dist/tools/verify-fix.d.ts +23 -0
- package/dist/tools/verify-fix.d.ts.map +1 -0
- package/dist/tools/verify-fix.js +211 -0
- package/dist/tools/verify-fix.js.map +1 -0
- package/dist/tools/weakmap-entries.d.ts.map +1 -1
- package/dist/tools/weakmap-entries.js +6 -1
- package/dist/tools/weakmap-entries.js.map +1 -1
- package/dist/utils.d.ts +204 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +478 -8
- package/dist/utils.js.map +1 -1
- package/package.json +5 -4
|
@@ -8,10 +8,152 @@
|
|
|
8
8
|
* @oncall memory_lab
|
|
9
9
|
*/
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
-
import { getSnapshot, getSnapshotMetadata } from '../heap-state.js';
|
|
11
|
+
import { getSnapshot, getSnapshotMetadata, getSessionConfig, } from '../heap-state.js';
|
|
12
12
|
import { formatBytes, formatNumber, markdownTable, errorResult, toolResult, } from '../utils.js';
|
|
13
|
+
import { collectDevRoots, computeReachableWithoutDevRoots, } from './dev-artifacts.js';
|
|
14
|
+
import { buildStringIndex } from '../string-index.js';
|
|
15
|
+
import { classifyNonProductionString, isHarnessToolManifestShape, } from '../artifact-classes.js';
|
|
16
|
+
// V8 splits a single logical object's storage across several internal backing
|
|
17
|
+
// structures: a `system / PropertyArray` (named props once the object grows past
|
|
18
|
+
// the inline slot count), `(object properties)` / `(object elements)` arrays, and
|
|
19
|
+
// `(sliced string)` views. A string reached through ANY of these is still held by
|
|
20
|
+
// ONE logical object via ONE assignment site — interning the property at the parse
|
|
21
|
+
// boundary collapses it (Feedback round 5 §1/§2). Only a referrer that is a
|
|
22
|
+
// genuinely INDEPENDENT user structure (a real Array element, or a property on a
|
|
23
|
+
// different logical object) keeps the per-row instance alive after interning and
|
|
24
|
+
// therefore makes the savings non-capturable. This predicate identifies the
|
|
25
|
+
// former so it is NOT mistaken for the latter.
|
|
26
|
+
function isOwnStorageReferrer(ref) {
|
|
27
|
+
// Hidden/internal edges are V8 wiring of the object's own representation
|
|
28
|
+
// (this covers the "(object properties) (internal)", "(object elements)
|
|
29
|
+
// (internal)", "(sliced string) (internal)", and "Object (internal)" buckets
|
|
30
|
+
// the feedback flagged as ambiguous — all of them are own-storage).
|
|
31
|
+
if (ref.type === 'hidden' || ref.type === 'internal')
|
|
32
|
+
return true;
|
|
33
|
+
const fromName = ref.fromNode.name || '';
|
|
34
|
+
return (fromName === 'system / PropertyArray' ||
|
|
35
|
+
fromName === 'system / SlicedString' ||
|
|
36
|
+
fromName.startsWith('(object properties)') ||
|
|
37
|
+
fromName.startsWith('(object elements)') ||
|
|
38
|
+
fromName.startsWith('(sliced string)'));
|
|
39
|
+
}
|
|
40
|
+
// The canonical parse-boundary intern fix caps at 128 chars (the skill's
|
|
41
|
+
// recommended cap: interning very long, low-cardinality strings costs a large
|
|
42
|
+
// unique-set retention for little collapse). So the "within-load capturable"
|
|
43
|
+
// figure double-counts value that a COMPLIANT fix would skip. Splitting savings
|
|
44
|
+
// at this length lets the headline report cappable (what the 128-char fix
|
|
45
|
+
// reclaims) separately from over-cap (needs an uncapped pool or a different fix)
|
|
46
|
+
// so the reported number matches what the recommended fix actually reclaims
|
|
47
|
+
// (sweep feedback §3).
|
|
48
|
+
const CANONICAL_CAP_CHARS = 128;
|
|
49
|
+
/**
|
|
50
|
+
* Label the array (and, one level up, its owner) that holds a duplicated
|
|
51
|
+
* array-element string, so columnar / rows-as-arrays duplication groups by a
|
|
52
|
+
* meaningful owner shape (e.g. `Query._rows[]`, `Array[][] (columnar rows)`)
|
|
53
|
+
* instead of a bare `Object`. Walks a bounded set of referrers with the
|
|
54
|
+
* non-materializing iterator (never the O(N) `.referrers` getter).
|
|
55
|
+
*/
|
|
56
|
+
function arrayOwnerLabel(arrayNode) {
|
|
57
|
+
const refs = [];
|
|
58
|
+
arrayNode.forEachReferrer(ref => {
|
|
59
|
+
refs.push(ref);
|
|
60
|
+
if (refs.length >= 8)
|
|
61
|
+
return { stop: true };
|
|
62
|
+
});
|
|
63
|
+
for (const ref of refs) {
|
|
64
|
+
const fromName = ref.fromNode.name || '';
|
|
65
|
+
if (ref.type === 'property' || ref.type === 'context') {
|
|
66
|
+
const owner = fromName && fromName !== 'Object' ? fromName : 'Object';
|
|
67
|
+
return `${owner}.${String(ref.name_or_index)}[]`;
|
|
68
|
+
}
|
|
69
|
+
if (ref.type === 'element') {
|
|
70
|
+
// An array held as an element of another array = matrix / columnar rows.
|
|
71
|
+
const owner = fromName && fromName !== 'Object' ? fromName : 'Array';
|
|
72
|
+
return `${owner}[][] (columnar rows)`;
|
|
73
|
+
}
|
|
74
|
+
if ((ref.type === 'hidden' || ref.type === 'internal') &&
|
|
75
|
+
fromName &&
|
|
76
|
+
fromName !== 'Object') {
|
|
77
|
+
return `${fromName}[]`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return arrayNode.name && arrayNode.name !== 'Object'
|
|
81
|
+
? `${arrayNode.name}[]`
|
|
82
|
+
: 'Array[]';
|
|
83
|
+
}
|
|
84
|
+
// --- Headline-accuracy heuristics (Feedback round 7) ----------------------
|
|
85
|
+
// Both operate purely on a group's already-aggregated counts / extracted shape
|
|
86
|
+
// props — no heap traversal — so they are O(groups), not O(nodes).
|
|
87
|
+
// Low-ROI: strings barely repeat (copies ÷ unique below the floor) AND are long
|
|
88
|
+
// on average. Interning needs a pool holding the whole unique set for a small
|
|
89
|
+
// collapse — high memory cost, low payoff (e.g. a ~141-char value at 2.2×).
|
|
90
|
+
const LOW_ROI_DUP_FLOOR = 3;
|
|
91
|
+
const LOW_ROI_AVG_BYTES = 128;
|
|
92
|
+
function isLowRoiGroup(uniqueStrings, totalCopies, totalSize) {
|
|
93
|
+
if (uniqueStrings <= 0 || totalCopies <= 0)
|
|
94
|
+
return false;
|
|
95
|
+
const dupFactor = totalCopies / uniqueStrings;
|
|
96
|
+
const avgBytes = totalSize / totalCopies;
|
|
97
|
+
return dupFactor < LOW_ROI_DUP_FLOOR && avgBytes > LOW_ROI_AVG_BYTES;
|
|
98
|
+
}
|
|
99
|
+
// Report a `(concatenated string)` (rope) buildup — string accumulation, not
|
|
100
|
+
// value duplication, which interning cannot help — once this many such nodes are
|
|
101
|
+
// present. Shared by the empty-results path and the main results header so the
|
|
102
|
+
// two call sites cannot drift out of sync.
|
|
103
|
+
const CONCAT_STRING_BUILDUP_FLOOR = 1_000_000;
|
|
104
|
+
// Framework/infra-owned: request headers, cookies, auth tokens, and Next.js
|
|
105
|
+
// URL/cache context. Matched by property name and by tell-tale property names in
|
|
106
|
+
// the parent shape.
|
|
107
|
+
// NOTE: deliberately excludes the generic single-word `via` — though HTTP `Via`
|
|
108
|
+
// is a real header, `via` is a plausible application property name and an exact
|
|
109
|
+
// match would misclassify app data as framework-owned. Real `Via` headers are
|
|
110
|
+
// still caught by the `x-`/`sec-` prefixes and the parent-shape header-bag check.
|
|
111
|
+
// The `.+_oauth_token` / `.+-access-token` alternatives intentionally match any
|
|
112
|
+
// non-empty prefix: a property name ending in those suffixes is auth-token data
|
|
113
|
+
// regardless of prefix. `.+` (not `.*`) keeps bare `_oauth_token` /
|
|
114
|
+
// `-access-token` from matching.
|
|
115
|
+
const FRAMEWORK_PROP_RE = /^(cookie|set-cookie|user-agent|accept-language|accept-encoding|referer|referrer|x-[a-z0-9-]+|sec-[a-z0-9-]+|proxied_to_master|.+_oauth_token|.+-access-token)$/i;
|
|
116
|
+
const FRAMEWORK_SHAPE_NAME_RE = /^(URLContext|IncomingMessage|ServerResponse)$/;
|
|
117
|
+
const FRAMEWORK_SHAPE_PROPS = new Set([
|
|
118
|
+
'cookie',
|
|
119
|
+
'user-agent',
|
|
120
|
+
'accept-language',
|
|
121
|
+
'x-fb-validated-client-cert',
|
|
122
|
+
'proxied_to_master',
|
|
123
|
+
'intern_oauth_token',
|
|
124
|
+
]);
|
|
125
|
+
function isFrameworkOwned(propertyName, parentShape, parentShapeProps) {
|
|
126
|
+
if (FRAMEWORK_PROP_RE.test(propertyName))
|
|
127
|
+
return true;
|
|
128
|
+
if (FRAMEWORK_SHAPE_NAME_RE.test(parentShape))
|
|
129
|
+
return true;
|
|
130
|
+
// Next.js incremental-cache / route context shape.
|
|
131
|
+
if (parentShapeProps.includes('defaultLocale') &&
|
|
132
|
+
parentShapeProps.includes('incrementalCache')) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
// A request-header bag: two or more header-ish props in the same shape.
|
|
136
|
+
// Deliberately heuristic, with two accepted trade-offs (kept conservative
|
|
137
|
+
// rather than tightened, since tuning needs real heap data):
|
|
138
|
+
// • False negative: `parentShapeProps` is the upstream sample, capped at 12
|
|
139
|
+
// entries and pre-sorted alphabetically, so an anonymous header bag whose
|
|
140
|
+
// 12 alphabetically-first props are not header-ish slips through. The
|
|
141
|
+
// common named shapes are still caught by FRAMEWORK_SHAPE_NAME_RE above.
|
|
142
|
+
// • False positive: an app object that genuinely models ≥2 of these
|
|
143
|
+
// HTTP-specific names (e.g. `cookie` + `user-agent`) is misclassified. The
|
|
144
|
+
// names are HTTP-specific enough that this is rare; `>= 2` (not `>= 1`)
|
|
145
|
+
// guards the most likely single-field collisions.
|
|
146
|
+
let headerish = 0;
|
|
147
|
+
for (const p of parentShapeProps) {
|
|
148
|
+
if (FRAMEWORK_SHAPE_PROPS.has(p))
|
|
149
|
+
headerish++;
|
|
150
|
+
}
|
|
151
|
+
return headerish >= 2;
|
|
152
|
+
}
|
|
13
153
|
export function registerInternOpportunities(server) {
|
|
14
|
-
server.tool('memlab_intern_opportunities', 'Identify string interning opportunities by grouping duplicated strings by the property name and parent object shape that holds them. Shows total savings per (property × shape) combination — the key metric for deciding where to add a string interning pool. Replaces the manual workflow of: duplicated_strings → retainer_summary → codebase grep. ' +
|
|
154
|
+
server.tool('memlab_intern_opportunities', 'Identify string interning opportunities by grouping duplicated strings by the property name and parent object shape that holds them. Shows total savings per (property × shape) combination — the key metric for deciding where to add a string interning pool. Also surfaces ARRAY-ELEMENT / columnar duplication (strings held as elements of a rowsAsArray / string[][] result buffer — a common Nest mysql2/Drizzle shape) as first-class groups keyed by column index and array-owner shape, folded into the within-load headline; these are marked with a filled square and fixed by interning at the array-construction/parse site. The within-load figure is split at the canonical 128-char intern cap into cappable (<=128 chars, what the recommended fix reclaims) vs over-cap (longer strings the cap skips), so the headline matches what a compliant fix actually reclaims. Replaces the manual workflow of: duplicated_strings → retainer_summary → codebase grep. ' +
|
|
155
|
+
'Retention-aware: flags groups whose duplicated instances are ALSO held by another structure (e.g. a raw array/matrix) as "co-retained" — interning the property there reclaims ~0, so the savings are reported separately and you must dedupe the shared source instead. ' +
|
|
156
|
+
'The headline "within-load capturable" figure also excludes framework/infra-owned strings (HTTP headers, cookies, auth tokens, Next.js URL/cache context), measurement-harness content (the CDP/devtools bridge injected to drive the session — an injected bundle can be the single largest "opportunity" in a driven snapshot while reclaiming nothing in production), and low-ROI groups (high-cardinality + long strings, where the intern pool costs more than it saves), reporting each in its own bucket; and it flags concatenated-string (rope) buildup, which is accumulation rather than duplication and cannot be interned. ' +
|
|
15
157
|
'⚠ Full-heap scan (builds string-duplication groups) — slow and memory-heavy on very large heaps (millions of nodes); raise min_copies / min_savings to bound it.', {
|
|
16
158
|
limit: z
|
|
17
159
|
.number()
|
|
@@ -28,38 +170,45 @@ export function registerInternOpportunities(server) {
|
|
|
28
170
|
.optional()
|
|
29
171
|
.default(102400)
|
|
30
172
|
.describe('Minimum savings in bytes to include a group (default 100 KB)'),
|
|
31
|
-
|
|
173
|
+
summary_only: z
|
|
174
|
+
.boolean()
|
|
175
|
+
.optional()
|
|
176
|
+
.default(false)
|
|
177
|
+
.describe('Triage mode: return only the headline savings split (within-load / co-retained / cross-load), a one-line verdict, and the ranked group table — dropping the per-group top-strings, the "How to fix" block, and "Next steps". Ideal for screening many snapshots without flooding context.'),
|
|
178
|
+
}, async ({ limit, min_copies, min_savings, summary_only }) => {
|
|
32
179
|
try {
|
|
33
180
|
const snapshot = getSnapshot();
|
|
34
181
|
const meta = getSnapshotMetadata();
|
|
35
182
|
const totalSize = meta?.totalSize ?? 0;
|
|
36
|
-
// Step 1:
|
|
37
|
-
|
|
38
|
-
snapshot
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
183
|
+
// Step 1: per-value string duplication index.
|
|
184
|
+
//
|
|
185
|
+
// Shared with memlab_duplicated_strings and cached per snapshot, so the
|
|
186
|
+
// two tools (routinely run back to back) walk the string nodes once
|
|
187
|
+
// between them rather than once each. The dev-root reachability pass is
|
|
188
|
+
// requested here because only this tool needs it: strings retained ONLY
|
|
189
|
+
// through a dev/automation root — overwhelmingly the attached
|
|
190
|
+
// inspector's console holding dev-build log records — cannot be
|
|
191
|
+
// reclaimed by an intern pool in production, and counting them inflates
|
|
192
|
+
// the headline saving.
|
|
193
|
+
//
|
|
194
|
+
// `(concatenated string)` (cons/rope) nodes are counted by the same
|
|
195
|
+
// index: a heap dominated by them is string ACCUMULATION (repeated `+=`
|
|
196
|
+
// / join into a long-lived buffer), NOT value duplication, and
|
|
197
|
+
// interning cannot help. Flagging the pattern beats reporting a tiny
|
|
198
|
+
// interning win against it. (Feedback round 7 §4.)
|
|
199
|
+
const devRoots = collectDevRoots(snapshot);
|
|
200
|
+
const productionReachable = devRoots.byId.size > 0
|
|
201
|
+
? computeReachableWithoutDevRoots(snapshot, devRoots)
|
|
202
|
+
: null;
|
|
203
|
+
const isDevOnlyNode = (node) => productionReachable != null &&
|
|
204
|
+
productionReachable[node.nodeIndex] === 0;
|
|
205
|
+
const stringIndex = buildStringIndex(snapshot, {
|
|
206
|
+
withDevOnly: true,
|
|
207
|
+
isDevOnlyNode,
|
|
62
208
|
});
|
|
209
|
+
const stringMap = stringIndex.byValue;
|
|
210
|
+
const concatStringCount = stringIndex.concatStringCount;
|
|
211
|
+
const concatStringSize = stringIndex.concatStringSize;
|
|
63
212
|
// Step 2: For duplicated strings, sample referrers to get property × shape
|
|
64
213
|
const groupMap = new Map();
|
|
65
214
|
for (const [value, stats] of stringMap) {
|
|
@@ -72,44 +221,153 @@ export function registerInternOpportunities(server) {
|
|
|
72
221
|
const node = snapshot.getNodeById(nodeId);
|
|
73
222
|
if (!node)
|
|
74
223
|
continue;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
224
|
+
// Gather a bounded set of this instance's referrers once, so we can
|
|
225
|
+
// both (a) pick the primary property/context referrer for grouping
|
|
226
|
+
// and (b) detect co-retention: if the exact same string instance is
|
|
227
|
+
// also held by an INDEPENDENT structure (e.g. a raw array/matrix
|
|
228
|
+
// cell on a different object), interning at the property assignment
|
|
229
|
+
// site frees ~nothing because the other referrer keeps the per-row
|
|
230
|
+
// instance alive. This is the difference between a fix that reclaims
|
|
231
|
+
// the bytes and one that reclaims ~0 (Feedback round 4 §1:
|
|
232
|
+
// retention-aware savings).
|
|
233
|
+
// Collect up to 8 referrers via the streaming iterator, NOT the
|
|
234
|
+
// `node.referrers` getter: that getter materializes a JS array of
|
|
235
|
+
// ALL incoming edges on every access, so a string referenced N times
|
|
236
|
+
// (common low-cardinality values are referenced 1000s of times) pays
|
|
237
|
+
// O(N) per sample. forEachReferrer stops after 8 without building the
|
|
238
|
+
// full array.
|
|
239
|
+
const refs = [];
|
|
240
|
+
node.forEachReferrer(ref => {
|
|
241
|
+
refs.push(ref);
|
|
242
|
+
if (refs.length >= 8)
|
|
243
|
+
return { stop: true };
|
|
244
|
+
});
|
|
245
|
+
// Primary referrer = first property/context edge (assignment site).
|
|
246
|
+
// If there is none, fall back to an array-element referrer so strings
|
|
247
|
+
// held as ARRAY ELEMENTS (columnar / rows-as-arrays — a very common
|
|
248
|
+
// Nest shape: mysql2/Drizzle rowsAsArray SELECT buffers) become a
|
|
249
|
+
// first-class group instead of being silently dropped. Previously the
|
|
250
|
+
// biggest single win of a sweep could hide because it was columnar
|
|
251
|
+
// (sweep feedback §2).
|
|
252
|
+
let primary = null;
|
|
253
|
+
for (const ref of refs) {
|
|
254
|
+
if (ref.type === 'property' || ref.type === 'context') {
|
|
255
|
+
primary = ref;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
let isArrayElement = false;
|
|
260
|
+
if (!primary) {
|
|
261
|
+
for (const ref of refs) {
|
|
262
|
+
if (ref.type === 'element') {
|
|
263
|
+
primary = ref;
|
|
264
|
+
isArrayElement = true;
|
|
265
|
+
break;
|
|
86
266
|
}
|
|
87
267
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
268
|
+
}
|
|
269
|
+
if (!primary)
|
|
270
|
+
continue;
|
|
271
|
+
const parent = primary.fromNode;
|
|
272
|
+
// For array elements, key the "property" by the column index so a
|
|
273
|
+
// fixed column across rows groups together (e.g. `[3]`); named props
|
|
274
|
+
// key by their name as before.
|
|
275
|
+
const propName = isArrayElement
|
|
276
|
+
? `[${String(primary.name_or_index)}]`
|
|
277
|
+
: String(primary.name_or_index);
|
|
278
|
+
// Co-retained ONLY if a referrer comes from a genuinely independent
|
|
279
|
+
// structure. A referrer that is the same object's own V8 backing
|
|
280
|
+
// store (PropertyArray / object-elements / sliced-string, or any
|
|
281
|
+
// hidden/internal edge) still collapses under interning, so it must
|
|
282
|
+
// NOT be counted as co-retention — counting it was discounting the
|
|
283
|
+
// biggest real wins by ~2× (Feedback round 5 §1/§2).
|
|
284
|
+
let coRetainedVia;
|
|
285
|
+
for (const ref of refs) {
|
|
286
|
+
if (ref.fromNode.id === parent.id)
|
|
287
|
+
continue;
|
|
288
|
+
if (isOwnStorageReferrer(ref))
|
|
289
|
+
continue;
|
|
290
|
+
const fromName = ref.fromNode.name || 'Object';
|
|
291
|
+
if (ref.type === 'element') {
|
|
292
|
+
coRetainedVia =
|
|
293
|
+
(fromName === 'Object' || fromName === ''
|
|
294
|
+
? 'Array'
|
|
295
|
+
: fromName) + '[] (array element)';
|
|
296
|
+
}
|
|
297
|
+
else if (ref.type === 'property' || ref.type === 'context') {
|
|
298
|
+
coRetainedVia = `${fromName}.${String(ref.name_or_index)}`;
|
|
99
299
|
}
|
|
100
300
|
else {
|
|
101
|
-
|
|
102
|
-
sampledCount: 1,
|
|
103
|
-
propName,
|
|
104
|
-
parentProps,
|
|
105
|
-
shapeKey,
|
|
106
|
-
parentId: parent.id,
|
|
107
|
-
sampleRetained: node.retainedSize,
|
|
108
|
-
});
|
|
301
|
+
coRetainedVia = `${fromName} (${ref.type})`;
|
|
109
302
|
}
|
|
110
|
-
samplesProcessed++;
|
|
111
303
|
break;
|
|
112
304
|
}
|
|
305
|
+
// Fingerprint the parent shape from up to 12 property names.
|
|
306
|
+
// CRITICAL: never touch `parent.references` — that getter
|
|
307
|
+
// materializes a JS array of ALL outgoing edges on every access, so
|
|
308
|
+
// on a giant parent (a map/config object, or one with a huge backing
|
|
309
|
+
// store) it is O(edge_count) PER SAMPLE PER duplicated string and
|
|
310
|
+
// wedges the tool (observed: avg 185k, max 504k edges, ~390k of
|
|
311
|
+
// ~978k samples on one real heap). `edge_count` is O(1): skip shape
|
|
312
|
+
// detection for oversized parents (they are not the row-shaped
|
|
313
|
+
// objects we intern — grouping them by node name is enough), and for
|
|
314
|
+
// the rest use the non-materializing `forEachReference` iterator with
|
|
315
|
+
// an absolute visit cap as a backstop.
|
|
316
|
+
const parentProps = [];
|
|
317
|
+
let shapeKey;
|
|
318
|
+
if (isArrayElement) {
|
|
319
|
+
// Arrays carry element edges, not a property shape — label by the
|
|
320
|
+
// array's owner (one level up) so columnar rows group by owner.
|
|
321
|
+
shapeKey = arrayOwnerLabel(parent);
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
const PARENT_EDGE_GUARD = 1024;
|
|
325
|
+
const PARENT_SCAN_CAP = 256;
|
|
326
|
+
if (parent.edge_count <= PARENT_EDGE_GUARD) {
|
|
327
|
+
let scanned = 0;
|
|
328
|
+
parent.forEachReference(edge => {
|
|
329
|
+
if (++scanned > PARENT_SCAN_CAP)
|
|
330
|
+
return { stop: true };
|
|
331
|
+
if (edge.type === 'property') {
|
|
332
|
+
parentProps.push(String(edge.name_or_index));
|
|
333
|
+
if (parentProps.length >= 12)
|
|
334
|
+
return { stop: true };
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
parentProps.sort();
|
|
339
|
+
shapeKey =
|
|
340
|
+
parent.name !== 'Object'
|
|
341
|
+
? parent.name
|
|
342
|
+
: parentProps.length > 0
|
|
343
|
+
? `{${parentProps.join(',')}}`
|
|
344
|
+
: 'Object';
|
|
345
|
+
}
|
|
346
|
+
const groupKey = `${propName}::${shapeKey}`;
|
|
347
|
+
const dist = groupDist.get(groupKey);
|
|
348
|
+
if (dist) {
|
|
349
|
+
dist.sampledCount++;
|
|
350
|
+
dist.sampleRetained += node.retainedSize;
|
|
351
|
+
if (coRetainedVia) {
|
|
352
|
+
dist.coRetainedCount++;
|
|
353
|
+
if (!dist.coRetainedVia)
|
|
354
|
+
dist.coRetainedVia = coRetainedVia;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
groupDist.set(groupKey, {
|
|
359
|
+
sampledCount: 1,
|
|
360
|
+
propName,
|
|
361
|
+
parentProps,
|
|
362
|
+
shapeKey,
|
|
363
|
+
parentId: parent.id,
|
|
364
|
+
sampleRetained: node.retainedSize,
|
|
365
|
+
coRetainedCount: coRetainedVia ? 1 : 0,
|
|
366
|
+
coRetainedVia,
|
|
367
|
+
arrayElement: isArrayElement,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
samplesProcessed++;
|
|
113
371
|
}
|
|
114
372
|
if (samplesProcessed === 0)
|
|
115
373
|
continue;
|
|
@@ -118,6 +376,8 @@ export function registerInternOpportunities(server) {
|
|
|
118
376
|
const scaleFactor = stats.count / samplesProcessed;
|
|
119
377
|
const trueCount = Math.round(dist.sampledCount * scaleFactor);
|
|
120
378
|
const trueSize = stats.totalSize * (dist.sampledCount / samplesProcessed);
|
|
379
|
+
const devOnlyShare = stats.count > 0 ? stats.devOnlyCount / stats.count : 0;
|
|
380
|
+
const trueDevOnly = trueCount * devOnlyShare;
|
|
121
381
|
const existing = groupMap.get(groupKey);
|
|
122
382
|
if (existing) {
|
|
123
383
|
const strEntry = existing.strings.get(value);
|
|
@@ -133,6 +393,19 @@ export function registerInternOpportunities(server) {
|
|
|
133
393
|
}
|
|
134
394
|
existing.totalCopies += trueCount;
|
|
135
395
|
existing.totalSize += trueSize;
|
|
396
|
+
existing.devOnlyCopies += trueDevOnly;
|
|
397
|
+
existing.groupSamples += dist.sampledCount;
|
|
398
|
+
existing.coRetainedSamples += dist.coRetainedCount;
|
|
399
|
+
if (!existing.coRetainedVia && dist.coRetainedVia) {
|
|
400
|
+
existing.coRetainedVia = dist.coRetainedVia;
|
|
401
|
+
}
|
|
402
|
+
// OR the flag across samples rather than trusting the first: if a
|
|
403
|
+
// key is ever reached from both an element and a non-element
|
|
404
|
+
// referrer (e.g. a property literally named `[N]`), the group is
|
|
405
|
+
// still treated as columnar so the label/marker/totals stay
|
|
406
|
+
// consistent.
|
|
407
|
+
existing.arrayElement =
|
|
408
|
+
existing.arrayElement || dist.arrayElement;
|
|
136
409
|
}
|
|
137
410
|
else {
|
|
138
411
|
groupMap.set(groupKey, {
|
|
@@ -142,7 +415,12 @@ export function registerInternOpportunities(server) {
|
|
|
142
415
|
strings: new Map([[value, { count: trueCount, size: trueSize }]]),
|
|
143
416
|
totalCopies: trueCount,
|
|
144
417
|
totalSize: trueSize,
|
|
418
|
+
devOnlyCopies: trueDevOnly,
|
|
145
419
|
exampleParentId: dist.parentId,
|
|
420
|
+
groupSamples: dist.sampledCount,
|
|
421
|
+
coRetainedSamples: dist.coRetainedCount,
|
|
422
|
+
coRetainedVia: dist.coRetainedVia,
|
|
423
|
+
arrayElement: dist.arrayElement,
|
|
146
424
|
});
|
|
147
425
|
}
|
|
148
426
|
}
|
|
@@ -153,11 +431,36 @@ export function registerInternOpportunities(server) {
|
|
|
153
431
|
if (g.totalCopies < min_copies)
|
|
154
432
|
continue;
|
|
155
433
|
let savingsIfInterned = 0;
|
|
434
|
+
let savingsCappable = 0;
|
|
435
|
+
let savingsOverCap = 0;
|
|
436
|
+
// Savings attributable to harness-injected string CONTENT. Compared
|
|
437
|
+
// against savingsIfInterned below rather than tripping on the first
|
|
438
|
+
// match, so one stray bridge string in an otherwise-app group does
|
|
439
|
+
// not relabel the whole group.
|
|
440
|
+
let savingsHarness = 0;
|
|
441
|
+
let harnessWhat;
|
|
156
442
|
const topStrings = [];
|
|
157
443
|
for (const [value, strStats] of g.strings) {
|
|
158
444
|
if (strStats.count > 1) {
|
|
159
445
|
const perCopy = strStats.size / strStats.count;
|
|
160
|
-
|
|
446
|
+
const s = (strStats.count - 1) * perCopy;
|
|
447
|
+
savingsIfInterned += s;
|
|
448
|
+
// A ≤128-char value is what the canonical intern fix would cap and
|
|
449
|
+
// reclaim; longer values are skipped by that cap (feedback §3).
|
|
450
|
+
if (value.length <= CANONICAL_CAP_CHARS)
|
|
451
|
+
savingsCappable += s;
|
|
452
|
+
else
|
|
453
|
+
savingsOverCap += s;
|
|
454
|
+
const nonProd = classifyNonProductionString(value);
|
|
455
|
+
if (nonProd != null) {
|
|
456
|
+
savingsHarness += s;
|
|
457
|
+
if (!harnessWhat) {
|
|
458
|
+
harnessWhat =
|
|
459
|
+
nonProd.kind === 'dev-build'
|
|
460
|
+
? `${nonProd.what} — DEV build only, absent in production`
|
|
461
|
+
: nonProd.what;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
161
464
|
}
|
|
162
465
|
topStrings.push({
|
|
163
466
|
value,
|
|
@@ -168,6 +471,20 @@ export function registerInternOpportunities(server) {
|
|
|
168
471
|
if (savingsIfInterned < min_savings)
|
|
169
472
|
continue;
|
|
170
473
|
topStrings.sort((a, b) => b.size - a.size);
|
|
474
|
+
// Co-retained when the majority of sampled instances are also held by
|
|
475
|
+
// another structure — interning the property alone reclaims ~0.
|
|
476
|
+
const coRetained = g.coRetainedSamples > 0 &&
|
|
477
|
+
g.coRetainedSamples >= g.groupSamples * 0.5;
|
|
478
|
+
// Harness by CONTENT (majority of the group's savings sits in
|
|
479
|
+
// bridge-injected strings) or by SHAPE (the parent is one of the
|
|
480
|
+
// bridge's tool-manifest entries, whose long `.description` strings a
|
|
481
|
+
// per-load pool would happily "save"). The shape test is what catches
|
|
482
|
+
// the manifest — its descriptions are ordinary English and no content
|
|
483
|
+
// signature can distinguish them from app copy.
|
|
484
|
+
const harnessShape = isHarnessToolManifestShape(g.parentShapeProps);
|
|
485
|
+
const harness = harnessShape ||
|
|
486
|
+
(savingsIfInterned > 0 &&
|
|
487
|
+
savingsHarness >= savingsIfInterned * 0.5);
|
|
171
488
|
groups.push({
|
|
172
489
|
propertyName: g.propertyName,
|
|
173
490
|
parentShape: g.parentShapeKey,
|
|
@@ -176,14 +493,59 @@ export function registerInternOpportunities(server) {
|
|
|
176
493
|
totalCopies: g.totalCopies,
|
|
177
494
|
totalSize: g.totalSize,
|
|
178
495
|
savingsIfInterned,
|
|
496
|
+
savingsCappable,
|
|
497
|
+
savingsOverCap,
|
|
179
498
|
topStrings: topStrings.slice(0, 3),
|
|
180
499
|
exampleParentId: g.exampleParentId,
|
|
500
|
+
arrayElement: g.arrayElement,
|
|
501
|
+
coRetained,
|
|
502
|
+
coRetainedVia: coRetained ? g.coRetainedVia : undefined,
|
|
503
|
+
lowRoi: isLowRoiGroup(g.strings.size, g.totalCopies, g.totalSize),
|
|
504
|
+
frameworkOwned: isFrameworkOwned(g.propertyName, g.parentShapeKey, g.parentShapeProps),
|
|
505
|
+
// Majority-dev-only rather than any-dev-only: a group can legitimately
|
|
506
|
+
// mix a few console-held copies with real app data, and only a group
|
|
507
|
+
// that is essentially all inspector-held should leave the headline.
|
|
508
|
+
devOnly: g.totalCopies > 0 && g.devOnlyCopies / g.totalCopies >= 0.8,
|
|
509
|
+
harness,
|
|
510
|
+
harnessWhat: harness
|
|
511
|
+
? (harnessWhat ??
|
|
512
|
+
'automation/devtools bridge tool manifest (.description strings)')
|
|
513
|
+
: undefined,
|
|
181
514
|
});
|
|
182
515
|
}
|
|
183
516
|
groups.sort((a, b) => b.savingsIfInterned - a.savingsIfInterned);
|
|
184
517
|
const shown = groups.slice(0, limit);
|
|
185
518
|
if (shown.length === 0) {
|
|
186
|
-
|
|
519
|
+
// The property×shape grouping above only sees strings held as named
|
|
520
|
+
// OBJECT PROPERTIES. Strings held as ARRAY ELEMENTS (columnar /
|
|
521
|
+
// rows-as-arrays — e.g. a DB driver's string[][] result buffer) never
|
|
522
|
+
// form a property group, so a heap can show millions of duplicated
|
|
523
|
+
// cells yet report zero opportunities here. Cross-check the already-
|
|
524
|
+
// built stringMap (in-memory Map iteration — no extra heap traversal)
|
|
525
|
+
// and surface the heaviest duplicates so the user isn't dead-ended.
|
|
526
|
+
const arrayDupes = [];
|
|
527
|
+
for (const [value, s] of stringMap) {
|
|
528
|
+
if (s.count < min_copies)
|
|
529
|
+
continue;
|
|
530
|
+
const savings = (s.totalSize * (s.count - 1)) / s.count;
|
|
531
|
+
if (savings < min_savings)
|
|
532
|
+
continue;
|
|
533
|
+
arrayDupes.push({ value, count: s.count, savings });
|
|
534
|
+
}
|
|
535
|
+
arrayDupes.sort((a, b) => b.savings - a.savings);
|
|
536
|
+
let msg = `No significant interning opportunities found (min ${formatNumber(min_copies)} copies, min ${formatBytes(min_savings)} savings). Try lowering thresholds. If the heap is instead dominated by a few large strings/objects (not many small duplicates), interning won't help — use memlab_largest_objects or memlab_sliced_strings to investigate blob retention.`;
|
|
537
|
+
if (concatStringCount > CONCAT_STRING_BUILDUP_FLOOR) {
|
|
538
|
+
msg += `\n\n⚠ Concatenated-string buildup: ${formatNumber(concatStringCount)} \`(concatenated string)\` nodes (~${formatBytes(concatStringSize)} self-size). This is string ACCUMULATION (repeated \`+=\` / join into a long-lived buffer), NOT value duplication — interning cannot help. Investigate with memlab_largest_objects / memlab_sliced_strings and trace the retaining structure.`;
|
|
539
|
+
}
|
|
540
|
+
if (arrayDupes.length > 0) {
|
|
541
|
+
const totalDup = arrayDupes.reduce((a, d) => a + d.savings, 0);
|
|
542
|
+
const top = arrayDupes
|
|
543
|
+
.slice(0, 5)
|
|
544
|
+
.map(d => ` • ${JSON.stringify(d.value.length > 40 ? d.value.slice(0, 40) + '…' : d.value)} ×${formatNumber(d.count)} (~${formatBytes(d.savings)})`)
|
|
545
|
+
.join('\n');
|
|
546
|
+
msg += `\n\n⚠ However, ${formatNumber(arrayDupes.length)} value(s) are heavily duplicated (~${formatBytes(totalDup)} total) but were NOT surfaced as property groups above. This is expected when they are held as ARRAY ELEMENTS / columnar rows (e.g. a string[][] query-result buffer) rather than object properties — but it can also happen when their property groups fell below the min_copies / min_savings thresholds. If they are array elements, interning at the array-construction (parse) site collapses them; if they are properties, lower the thresholds to surface the group. Top:\n${top}\nUse memlab_search_strings to locate where each is built and confirm how it is held.`;
|
|
547
|
+
}
|
|
548
|
+
return toolResult(msg);
|
|
187
549
|
}
|
|
188
550
|
// Detect partial interning patterns (Feedback #3) and, while we're here,
|
|
189
551
|
// mark which groups' savings are CROSS-load (not capturable by a per-load
|
|
@@ -208,31 +570,109 @@ export function registerInternOpportunities(server) {
|
|
|
208
570
|
`suggests **${median} independent intern pools** instead of one shared pool. ` +
|
|
209
571
|
`Consolidating into a single shared pool would save ~${formatBytes(perPoolSavings)}.`);
|
|
210
572
|
}
|
|
211
|
-
// Split savings
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
573
|
+
// Split savings into mutually-exclusive buckets (Feedback round 4 §1 —
|
|
574
|
+
// retention-aware — extended in round 7):
|
|
575
|
+
// • co-retained — the duplicated instances are ALSO held by another
|
|
576
|
+
// structure, so interning the property reclaims ~0; the shared
|
|
577
|
+
// source must be deduped/dropped. Reported separately so the figure
|
|
578
|
+
// is not mistaken for an easy per-property win.
|
|
579
|
+
// • within-load — capturable by a per-load/per-request intern pool.
|
|
580
|
+
// • cross-load — needs a shared/module-scope pool (Feedback round 3 §1c).
|
|
581
|
+
// • framework — header/cookie/token/Next.js-context strings; not app
|
|
582
|
+
// data, so an app-level intern pool should not target them
|
|
583
|
+
// (Feedback round 7 §2).
|
|
584
|
+
// • low-ROI — high-cardinality + long strings whose pool cost dwarfs the
|
|
585
|
+
// collapse; usually skip (Feedback round 7 §1).
|
|
586
|
+
// Each group lands in exactly one bucket (precedence below) so the
|
|
587
|
+
// headline within-load figure reflects only realistic app-data wins.
|
|
588
|
+
// Framework/infra-owned is checked FIRST: "this is not application data"
|
|
589
|
+
// is the most fundamental classification and the most actionable label
|
|
590
|
+
// for the reader (skip it — it isn't yours), so a framework string is
|
|
591
|
+
// always surfaced as framework even when it is ALSO co-retained or
|
|
592
|
+
// duplicated across loads — HTTP request headers are duplicated across
|
|
593
|
+
// requests by nature, so a header would otherwise be miscounted as
|
|
594
|
+
// cross-load and the framework bucket under-counted. The remaining order
|
|
595
|
+
// is retention-then-ROI; every non-`within` bucket is excluded from the
|
|
596
|
+
// within-load headline regardless of which one a group lands in.
|
|
597
|
+
const bucketOf = (g) => {
|
|
598
|
+
// Checked before everything else: harness content is not the
|
|
599
|
+
// application's memory at all — it is the rig injected to observe it —
|
|
600
|
+
// so no other classification of it is meaningful.
|
|
601
|
+
if (g.harness)
|
|
602
|
+
return 'harness';
|
|
603
|
+
// Retention through a dev root means interning reclaims nothing in
|
|
604
|
+
// production, whatever the property is named.
|
|
605
|
+
if (g.devOnly)
|
|
606
|
+
return 'devOnly';
|
|
607
|
+
if (g.frameworkOwned)
|
|
608
|
+
return 'framework';
|
|
609
|
+
if (g.coRetained)
|
|
610
|
+
return 'coRetained';
|
|
611
|
+
if (g.crossLoad)
|
|
612
|
+
return 'crossLoad';
|
|
613
|
+
if (g.lowRoi)
|
|
614
|
+
return 'lowRoi';
|
|
615
|
+
return 'within';
|
|
616
|
+
};
|
|
617
|
+
const sumBucket = (bucket) => shown
|
|
618
|
+
.filter(g => bucketOf(g) === bucket)
|
|
219
619
|
.reduce((sum, g) => sum + g.savingsIfInterned, 0);
|
|
220
|
-
const
|
|
620
|
+
const coRetainedSavings = sumBucket('coRetained');
|
|
621
|
+
const crossLoadSavings = sumBucket('crossLoad');
|
|
622
|
+
const frameworkSavings = sumBucket('framework');
|
|
623
|
+
const devOnlySavings = sumBucket('devOnly');
|
|
624
|
+
const lowRoiSavings = sumBucket('lowRoi');
|
|
625
|
+
const harnessSavings = sumBucket('harness');
|
|
626
|
+
const withinLoadSavings = sumBucket('within');
|
|
627
|
+
// Within the capturable bucket, split by the canonical 128-char cap so the
|
|
628
|
+
// headline reports what a COMPLIANT fix reclaims, not the raw total that
|
|
629
|
+
// includes over-cap strings the fix skips (feedback §3). Also surface how
|
|
630
|
+
// much of the capturable win is columnar / array-element duplication so it
|
|
631
|
+
// is no longer buried (feedback §2).
|
|
632
|
+
const withinGroups = shown.filter(g => bucketOf(g) === 'within');
|
|
633
|
+
const withinCappable = withinGroups.reduce((s, g) => s + g.savingsCappable, 0);
|
|
634
|
+
const withinOverCap = withinGroups.reduce((s, g) => s + g.savingsOverCap, 0);
|
|
635
|
+
// Use the cappable portion (not the full savings) so this figure always
|
|
636
|
+
// fits inside the withinCappable headline — otherwise a columnar group
|
|
637
|
+
// with long strings could report "includes N of columnar" where N
|
|
638
|
+
// exceeds the leading cappable number, which reads as a contradiction.
|
|
639
|
+
const withinArrayElementCappable = withinGroups
|
|
640
|
+
.filter(g => g.arrayElement)
|
|
641
|
+
.reduce((s, g) => s + g.savingsCappable, 0);
|
|
642
|
+
const totalSavings = withinLoadSavings +
|
|
643
|
+
crossLoadSavings +
|
|
644
|
+
coRetainedSavings +
|
|
645
|
+
frameworkSavings +
|
|
646
|
+
devOnlySavings +
|
|
647
|
+
lowRoiSavings +
|
|
648
|
+
harnessSavings;
|
|
221
649
|
const pctOf = (n) => totalSize > 0
|
|
222
650
|
? ` (${((n / totalSize) * 100).toFixed(1)}% of heap)`
|
|
223
651
|
: '';
|
|
224
652
|
const pctOfHeap = pctOf(totalSavings);
|
|
653
|
+
// Duplication factor (copies ÷ unique) per group — the single best
|
|
654
|
+
// "is this cross-load?" signal: a value held ~N× by N same-shape objects
|
|
655
|
+
// each from a different call/load won't collapse under a per-request pool
|
|
656
|
+
// (Feedback round 5 §3). Reported as a column so the agent can eyeball it.
|
|
657
|
+
const fmtDup = (copies, unique) => {
|
|
658
|
+
if (unique <= 0)
|
|
659
|
+
return '-';
|
|
660
|
+
const f = copies / unique;
|
|
661
|
+
return f >= 10 ? `${Math.round(f)}×` : `${f.toFixed(1)}×`;
|
|
662
|
+
};
|
|
225
663
|
const headers = [
|
|
226
664
|
'Property',
|
|
227
665
|
'Parent Shape',
|
|
228
666
|
'Unique Strings',
|
|
229
667
|
'Total Copies',
|
|
668
|
+
'Dup ×',
|
|
669
|
+
'Avg len',
|
|
230
670
|
'Total Size',
|
|
231
671
|
'Savings',
|
|
232
672
|
'% Heap',
|
|
233
673
|
'Example Parent',
|
|
234
674
|
];
|
|
235
|
-
const rightCols = new Set([2, 3, 4, 5, 6]);
|
|
675
|
+
const rightCols = new Set([2, 3, 4, 5, 6, 7, 8]);
|
|
236
676
|
const rows = shown.map(g => {
|
|
237
677
|
const shape = g.parentShape.length > 40
|
|
238
678
|
? g.parentShape.slice(0, 37) + '…}'
|
|
@@ -240,32 +680,215 @@ export function registerInternOpportunities(server) {
|
|
|
240
680
|
const pct = totalSize > 0
|
|
241
681
|
? ((g.savingsIfInterned / totalSize) * 100).toFixed(1) + '%'
|
|
242
682
|
: '-';
|
|
683
|
+
const avgLen = g.totalCopies > 0
|
|
684
|
+
? formatBytes(Math.round(g.totalSize / g.totalCopies))
|
|
685
|
+
: '-';
|
|
686
|
+
// Array-element groups already read as `[3]`; a leading dot would
|
|
687
|
+
// produce a malformed label, so only prefix `.` for named properties.
|
|
688
|
+
const label = g.arrayElement ? g.propertyName : `.${g.propertyName}`;
|
|
243
689
|
return [
|
|
244
|
-
|
|
690
|
+
`${label}${g.arrayElement ? ' ▦' : ''}${g.coRetained ? ' ⚠' : ''}${g.crossLoad ? ' ⤫' : ''}${g.devOnly ? ' 🛠' : ''}${g.harness ? ' 🧪' : ''}${g.frameworkOwned ? ' ▤' : ''}${g.lowRoi ? ' ▽' : ''}`,
|
|
245
691
|
shape,
|
|
246
692
|
formatNumber(g.uniqueStrings),
|
|
247
693
|
formatNumber(g.totalCopies),
|
|
694
|
+
fmtDup(g.totalCopies, g.uniqueStrings),
|
|
695
|
+
avgLen,
|
|
248
696
|
formatBytes(g.totalSize),
|
|
249
697
|
formatBytes(g.savingsIfInterned),
|
|
250
698
|
pct,
|
|
251
699
|
`@${g.exampleParentId}`,
|
|
252
700
|
];
|
|
253
701
|
});
|
|
702
|
+
// Retention/concurrency-bug signature (Feedback round 6 §4): a heap
|
|
703
|
+
// dominated by CROSS-load groups whose copies ÷ unique ≈ 2.0 is two full
|
|
704
|
+
// copies of the same dataset resident at once (a stale+fresh
|
|
705
|
+
// double-buffer / setInterval retention), NOT a value duplicated within a
|
|
706
|
+
// single parse. A per-request intern pool cannot collapse it — the fix is
|
|
707
|
+
// to stop the double retention at the source. Detect it purely from the
|
|
708
|
+
// already-computed group counts (no extra heap traversal) so the verdict
|
|
709
|
+
// can call it out explicitly instead of leaving the agent to infer it.
|
|
710
|
+
const isExactlyTwoX = (g) => {
|
|
711
|
+
if (g.uniqueStrings <= 0)
|
|
712
|
+
return false;
|
|
713
|
+
const f = g.totalCopies / g.uniqueStrings;
|
|
714
|
+
return f >= 1.8 && f <= 2.2;
|
|
715
|
+
};
|
|
716
|
+
// Measure cross-load duplication from the raw `crossLoad` flag, NOT the
|
|
717
|
+
// post-precedence bucket. Framework-owned strings are reclassified out of
|
|
718
|
+
// the `crossLoad` bucket, but a stale+fresh double-buffer of e.g. HTTP
|
|
719
|
+
// headers is a real retention/concurrency bug regardless of who owns the
|
|
720
|
+
// data — and the verdict for it is explicitly "NOT interning". Gating on
|
|
721
|
+
// the bucket sum would let the detector silently stop firing once 2×
|
|
722
|
+
// framework groups leave the `crossLoad` bucket.
|
|
723
|
+
const crossLoadDupSavings = shown
|
|
724
|
+
.filter(g => g.crossLoad && !g.coRetained)
|
|
725
|
+
.reduce((sum, g) => sum + g.savingsIfInterned, 0);
|
|
726
|
+
const twoXCrossLoadSavings = shown
|
|
727
|
+
.filter(g => g.crossLoad && !g.coRetained && isExactlyTwoX(g))
|
|
728
|
+
.reduce((sum, g) => sum + g.savingsIfInterned, 0);
|
|
729
|
+
const retentionBugSuspected = crossLoadDupSavings > 0 &&
|
|
730
|
+
crossLoadDupSavings >= withinLoadSavings &&
|
|
731
|
+
crossLoadDupSavings >= coRetainedSavings &&
|
|
732
|
+
twoXCrossLoadSavings >= crossLoadDupSavings * 0.5;
|
|
733
|
+
// One-line verdict (Feedback round 5 §9): which bucket dominates decides
|
|
734
|
+
// the fix shape, so lead with it before the detail. A suspected
|
|
735
|
+
// retention/concurrency bug is checked FIRST — it is a real memory bug
|
|
736
|
+
// (NOT an interning win) and is framework-independent, so it must surface
|
|
737
|
+
// even when the 2× duplication is framework-owned and bucketed out of
|
|
738
|
+
// `crossLoad`.
|
|
739
|
+
// Every "mostly <bucket>" branch gates on the bucket being ≥ ALL other
|
|
740
|
+
// buckets (including framework/low-ROI), so the headline always names the
|
|
741
|
+
// bucket that actually dominates the heap. Without the framework/low-ROI
|
|
742
|
+
// comparison the cascade is asymmetric: a second-largest co-retained or
|
|
743
|
+
// cross-load bucket would be reported as the headline even when
|
|
744
|
+
// framework/low-ROI dominate (e.g. framework=100MB, crossLoad=10MB). The
|
|
745
|
+
// retention-bug branch is exempt — it is a correctness flag, not a
|
|
746
|
+
// "biggest bucket" claim, so it leads regardless of magnitude.
|
|
747
|
+
let verdict;
|
|
748
|
+
if (retentionBugSuspected) {
|
|
749
|
+
verdict = `Verdict: ⚠ likely **retention/concurrency bug** (NOT interning) — ${formatBytes(twoXCrossLoadSavings)} of cross-load duplication at ~2.0× (copies ÷ unique), i.e. two copies of the same dataset held at once (stale+fresh double-buffer / setInterval). A per-request intern pool will NOT help; fix the double retention at the source.`;
|
|
750
|
+
}
|
|
751
|
+
else if (coRetainedSavings >= withinLoadSavings &&
|
|
752
|
+
coRetainedSavings >= crossLoadSavings &&
|
|
753
|
+
coRetainedSavings >= frameworkSavings &&
|
|
754
|
+
coRetainedSavings >= lowRoiSavings &&
|
|
755
|
+
coRetainedSavings >= harnessSavings &&
|
|
756
|
+
coRetainedSavings > 0) {
|
|
757
|
+
verdict = `Verdict: mostly **co-retained** (${formatBytes(coRetainedSavings)}) — interning the property won't help; dedupe at the shared source.`;
|
|
758
|
+
}
|
|
759
|
+
else if (crossLoadSavings >= withinLoadSavings &&
|
|
760
|
+
crossLoadSavings >= frameworkSavings &&
|
|
761
|
+
crossLoadSavings >= lowRoiSavings &&
|
|
762
|
+
crossLoadSavings >= harnessSavings &&
|
|
763
|
+
crossLoadSavings > 0) {
|
|
764
|
+
verdict = `Verdict: mostly **cross-load** (${formatBytes(crossLoadSavings)}) — a per-request pool won't collapse it; needs a shared/module-scope pool or a retention fix.`;
|
|
765
|
+
}
|
|
766
|
+
else if (withinLoadSavings > 0 &&
|
|
767
|
+
withinLoadSavings >= frameworkSavings &&
|
|
768
|
+
withinLoadSavings >= lowRoiSavings &&
|
|
769
|
+
withinLoadSavings >= harnessSavings) {
|
|
770
|
+
// Lead with the ≤128-char cappable figure — the amount the canonical
|
|
771
|
+
// intern fix actually reclaims — not the raw within-load total that also
|
|
772
|
+
// counts over-cap strings the fix skips (feedback §3).
|
|
773
|
+
const capNote = withinOverCap > 0
|
|
774
|
+
? ` (+${formatBytes(withinOverCap)} in >${CANONICAL_CAP_CHARS}-char strings the 128-char cap skips — needs an uncapped pool or a different fix)`
|
|
775
|
+
: '';
|
|
776
|
+
const columnarNote = withinArrayElementCappable > 0
|
|
777
|
+
? ` Includes ${formatBytes(withinArrayElementCappable)} of columnar / array-element (rowsAsArray) duplication — intern at the array-construction/parse site.`
|
|
778
|
+
: '';
|
|
779
|
+
verdict = `Verdict: **${formatBytes(withinCappable)} cappable** by the canonical ≤${CANONICAL_CAP_CHARS}-char per-load/per-request intern pool at the parse boundary${capNote}.${columnarNote}`;
|
|
780
|
+
}
|
|
781
|
+
else if (frameworkSavings + lowRoiSavings + harnessSavings > 0) {
|
|
782
|
+
// Framework/low-ROI/harness dominate (each ≥ the within-load figure).
|
|
783
|
+
// Lead with them so a small capturable remainder isn't mistaken for the
|
|
784
|
+
// headline, but still name that remainder when nonzero so it isn't
|
|
785
|
+
// hidden.
|
|
786
|
+
const parts = [];
|
|
787
|
+
if (harnessSavings > 0) {
|
|
788
|
+
parts.push('measurement-harness content (the injected automation/devtools bridge — not application memory)');
|
|
789
|
+
}
|
|
790
|
+
if (frameworkSavings > 0) {
|
|
791
|
+
parts.push('framework/infra-owned (headers/cookies/tokens)');
|
|
792
|
+
}
|
|
793
|
+
if (lowRoiSavings > 0) {
|
|
794
|
+
parts.push('low-ROI (high-cardinality, long strings)');
|
|
795
|
+
}
|
|
796
|
+
const remainder = withinLoadSavings > 0
|
|
797
|
+
? ` (only ${formatBytes(withinLoadSavings)} is app-capturable by a per-load pool)`
|
|
798
|
+
: '';
|
|
799
|
+
verdict = `Verdict: largely no app-actionable interning${remainder} — the bulk is ${parts.join(' and ')}, which a per-load app intern pool should not target.`;
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
verdict =
|
|
803
|
+
'Verdict: no clearly-capturable interning savings — if the heap is dominated by a few large strings/objects, use memlab_largest_objects or memlab_sliced_strings to investigate blob retention.';
|
|
804
|
+
}
|
|
254
805
|
const headerLines = [
|
|
255
806
|
`# String Interning Opportunities`,
|
|
256
807
|
'',
|
|
808
|
+
verdict,
|
|
809
|
+
'',
|
|
257
810
|
`**Total duplication across top ${shown.length} groups: ${formatBytes(totalSavings)}${pctOfHeap}**`,
|
|
258
811
|
`- **Within-load (capturable by a per-load/per-request intern pool): ${formatBytes(withinLoadSavings)}${pctOf(withinLoadSavings)}**`,
|
|
259
812
|
];
|
|
813
|
+
// Split the within-load figure at the canonical 128-char cap so the
|
|
814
|
+
// reported number matches what the recommended fix actually reclaims
|
|
815
|
+
// (feedback §3). Only shown when there's over-cap value to distinguish —
|
|
816
|
+
// otherwise the whole within-load figure is already cappable.
|
|
817
|
+
if (withinOverCap > 0) {
|
|
818
|
+
headerLines.push(` - ≤${CANONICAL_CAP_CHARS}-char cappable (what the canonical 128-char intern fix reclaims): ${formatBytes(withinCappable)}`, ` - >${CANONICAL_CAP_CHARS}-char over-cap (skipped by the 128-char cap — needs an uncapped pool or a different fix): ${formatBytes(withinOverCap)}`);
|
|
819
|
+
}
|
|
820
|
+
if (withinArrayElementCappable > 0) {
|
|
821
|
+
headerLines.push(` - ▦ of which columnar / array-element (rowsAsArray) duplication (cappable): ${formatBytes(withinArrayElementCappable)} — intern at the array-construction/parse site`);
|
|
822
|
+
}
|
|
823
|
+
if (coRetainedSavings > 0) {
|
|
824
|
+
headerLines.push(`- **⚠ Co-retained (interning the property reclaims ~0 — these instances are ALSO held by an independent structure, e.g. a raw array/matrix on another object; dedupe at that shared source or drop it): ${formatBytes(coRetainedSavings)}${pctOf(coRetainedSavings)}**`);
|
|
825
|
+
}
|
|
260
826
|
if (crossLoadSavings > 0) {
|
|
261
|
-
headerLines.push(`-
|
|
827
|
+
headerLines.push(`- **⤫ Cross-load (NOT capturable by a per-request pool — needs a shared/module-scope pool or a retention/concurrency fix): ${formatBytes(crossLoadSavings)}${pctOf(crossLoadSavings)}**`);
|
|
828
|
+
}
|
|
829
|
+
if (frameworkSavings > 0) {
|
|
830
|
+
headerLines.push(`- **▤ Framework/infra-owned (HTTP headers, cookies, auth tokens, Next.js URL/cache context — not app data; out of scope for an app-level intern pool): ${formatBytes(frameworkSavings)}${pctOf(frameworkSavings)}**`);
|
|
831
|
+
}
|
|
832
|
+
if (devOnlySavings > 0) {
|
|
833
|
+
headerLines.push(`- **🛠 Dev/automation-retained (held only via the attached inspector's console or another dev root — reclaims NOTHING in production; do not chase): ${formatBytes(devOnlySavings)}${pctOf(devOnlySavings)}**`);
|
|
834
|
+
}
|
|
835
|
+
if (harnessSavings > 0) {
|
|
836
|
+
const whats = [
|
|
837
|
+
...new Set(shown
|
|
838
|
+
.filter(g => g.harness && g.harnessWhat)
|
|
839
|
+
.map(g => g.harnessWhat)),
|
|
840
|
+
];
|
|
841
|
+
headerLines.push(`- **🧪 Measurement-harness content (${whats.join('; ')}) — the CDP/devtools bridge injected into the page to drive this session, NOT application memory; interning it reclaims nothing in production: ${formatBytes(harnessSavings)}${pctOf(harnessSavings)}**`);
|
|
842
|
+
}
|
|
843
|
+
if (lowRoiSavings > 0) {
|
|
844
|
+
headerLines.push(`- **▽ Low-ROI (high-cardinality, few repeats + long strings — a per-load pool retains a large unique set for a small collapse; usually skip): ${formatBytes(lowRoiSavings)}${pctOf(lowRoiSavings)}**`);
|
|
262
845
|
}
|
|
846
|
+
if (concatStringCount > CONCAT_STRING_BUILDUP_FLOOR) {
|
|
847
|
+
headerLines.push('', `⚠ **Concatenated-string buildup:** ${formatNumber(concatStringCount)} \`(concatenated string)\` nodes (~${formatBytes(concatStringSize)} self-size) — a rope/accumulation pattern (repeated \`+=\` / join into a long-lived buffer), NOT value duplication. Interning cannot help; investigate with \`memlab_largest_objects\` / \`memlab_sliced_strings\` and trace the retainer.`);
|
|
848
|
+
}
|
|
849
|
+
const coRetainedGroups = shown.filter(g => g.coRetained);
|
|
850
|
+
const hasArrayElement = shown.some(g => g.arrayElement);
|
|
263
851
|
const lines = [
|
|
264
852
|
...headerLines,
|
|
265
853
|
'',
|
|
266
854
|
markdownTable(headers, rows, rightCols),
|
|
267
855
|
'',
|
|
268
856
|
];
|
|
857
|
+
if (coRetainedGroups.length > 0 ||
|
|
858
|
+
crossLoadSavings > 0 ||
|
|
859
|
+
frameworkSavings > 0 ||
|
|
860
|
+
devOnlySavings > 0 ||
|
|
861
|
+
harnessSavings > 0 ||
|
|
862
|
+
lowRoiSavings > 0 ||
|
|
863
|
+
hasArrayElement) {
|
|
864
|
+
lines.push("▦ = array element (columnar / rowsAsArray — intern at the parse site); ⚠ = co-retained (interning won't reclaim); ⤫ = cross-load (high Dup ×, needs shared pool); ▤ = framework/infra-owned (not app data); 🛠 = dev/automation-retained (inspector console — reclaims nothing in production); 🧪 = measurement-harness content (injected automation/devtools bridge — not app memory); ▽ = low-ROI (high-cardinality + long; usually skip).", '');
|
|
865
|
+
}
|
|
866
|
+
// Triage mode: stop after the headline split + ranked table. Drops the
|
|
867
|
+
// per-group string lists, partial-interning detail, and the fix recipe
|
|
868
|
+
// — pure waste when screening many snapshots (Feedback round 5 §9).
|
|
869
|
+
if (summary_only) {
|
|
870
|
+
if (coRetainedGroups.length > 0) {
|
|
871
|
+
lines.push('## ⚠ Co-retained — interning the property will NOT reclaim these', '');
|
|
872
|
+
}
|
|
873
|
+
for (const g of coRetainedGroups) {
|
|
874
|
+
const shape = g.parentShape.length > 50
|
|
875
|
+
? g.parentShape.slice(0, 47) + '…}'
|
|
876
|
+
: g.parentShape;
|
|
877
|
+
lines.push(`- ⚠ \`.${g.propertyName}\` on \`${shape}\` — ${formatBytes(g.savingsIfInterned)} co-retained via **${g.coRetainedVia ?? 'another structure'}**`);
|
|
878
|
+
}
|
|
879
|
+
return toolResult(lines.join('\n'));
|
|
880
|
+
}
|
|
881
|
+
// Co-retained groups: interning the property frees ~0 (Feedback round 4 §1).
|
|
882
|
+
if (coRetainedGroups.length > 0) {
|
|
883
|
+
lines.push('## ⚠ Co-retained — interning the property will NOT reclaim these', '', 'Each duplicated value below is also referenced by another structure, so deduping it at the property assignment site frees ~nothing — the other referrer keeps every per-row instance alive. Dedupe at the **shared source** instead (e.g. intern the raw array/matrix cells at ingestion, or drop that structure once parsed). Confirm with `memlab_get_referrers` on an example instance.', '');
|
|
884
|
+
for (const g of coRetainedGroups) {
|
|
885
|
+
const shape = g.parentShape.length > 50
|
|
886
|
+
? g.parentShape.slice(0, 47) + '…}'
|
|
887
|
+
: g.parentShape;
|
|
888
|
+
lines.push(`- \`.${g.propertyName}\` on \`${shape}\` — ${formatBytes(g.savingsIfInterned)}; each instance also retained via **${g.coRetainedVia ?? 'another structure'}**`);
|
|
889
|
+
}
|
|
890
|
+
lines.push('');
|
|
891
|
+
}
|
|
269
892
|
// Show top strings for the top 5 groups
|
|
270
893
|
for (const g of shown.slice(0, 5)) {
|
|
271
894
|
lines.push(`### \`.${g.propertyName}\` on \`${g.parentShape.length > 60 ? g.parentShape.slice(0, 57) + '…}' : g.parentShape}\` — ${formatBytes(g.savingsIfInterned)} savings`);
|
|
@@ -278,7 +901,30 @@ export function registerInternOpportunities(server) {
|
|
|
278
901
|
if (partialInternAlerts.length > 0) {
|
|
279
902
|
lines.push('## Partial Interning Detected', '', 'Strings that are partially deduplicated — interned within each dataset/call but duplicated across them:', '', ...partialInternAlerts, '', '_This typically happens when `internStrings()` or a dedup function creates a new Map per call instead of sharing one across datasets. Fix: lift the intern pool to module scope or pass it as a parameter._', '');
|
|
280
903
|
}
|
|
281
|
-
|
|
904
|
+
// The fix recipe + next steps are "suggestions"; honor the session-level
|
|
905
|
+
// suppress flag so a long sweep doesn't repeat the same boilerplate on
|
|
906
|
+
// every snapshot (Feedback round 5 §9a).
|
|
907
|
+
if (!getSessionConfig().suppressSuggestions) {
|
|
908
|
+
lines.push('---', '', '**How to fix:** Add a string interning pool at the JSON.parse / API response boundary:', '```js', 'const internPool = new Map();', 'function intern(s) { let v = internPool.get(s); if (!v) { internPool.set(s, s); v = s; } return v; }', '// Apply to the property during ingestion:', '// obj.propertyName = intern(obj.propertyName);', '```', '', '**Next steps:**', `- Inspect example parent: \`memlab_object_shape(${shown[0].exampleParentId})\``,
|
|
909
|
+
// find_by_shape needs a property-shape fingerprint; array-element
|
|
910
|
+
// (columnar) top groups have none (parentShapeProps is empty), so the
|
|
911
|
+
// recipe would render an unactionable `properties []`. Emit it only for
|
|
912
|
+
// a named-property top group — the columnar case is covered by the
|
|
913
|
+
// array-element step below.
|
|
914
|
+
...(!shown[0].arrayElement && shown[0].parentShapeProps.length > 0
|
|
915
|
+
? [
|
|
916
|
+
`- Find all instances: \`memlab_find_by_shape\` with properties ${JSON.stringify(shown[0].parentShapeProps.slice(0, 5))}`,
|
|
917
|
+
]
|
|
918
|
+
: []), '- Search codebase for the constructor/factory that creates these objects', ...(hasArrayElement
|
|
919
|
+
? [
|
|
920
|
+
'- For ▦ array-element (columnar / rowsAsArray) groups: the strings are cells of a result buffer, not object properties. Intern each cell where the rows are built (the DB driver’s rowsAsArray mapping or the parse loop) — e.g. `row[col] = intern(row[col])` per duplicated column — not at a property assignment site.',
|
|
921
|
+
]
|
|
922
|
+
: []), ...(coRetainedGroups.length > 0
|
|
923
|
+
? [
|
|
924
|
+
'- For ⚠ co-retained groups: run `memlab_get_referrers` on an example instance to find the shared owner, then dedupe at that source (the property-level pool above will not help).',
|
|
925
|
+
]
|
|
926
|
+
: []));
|
|
927
|
+
}
|
|
282
928
|
return toolResult(lines.join('\n'));
|
|
283
929
|
}
|
|
284
930
|
catch (err) {
|