@hydranium/cli 1.0.0-next.10
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/LICENSE +21 -0
- package/README.md +240 -0
- package/heap-analysis/analyze-heap.mjs +798 -0
- package/heap-analysis/heap/core/aggregate.mjs +152 -0
- package/heap-analysis/heap/core/bucket-label.mjs +23 -0
- package/heap-analysis/heap/core/cutpoints.mjs +51 -0
- package/heap-analysis/heap/core/diff.mjs +56 -0
- package/heap-analysis/heap/core/format.mjs +47 -0
- package/heap-analysis/heap/core/holders.mjs +80 -0
- package/heap-analysis/heap/core/load.mjs +46 -0
- package/heap-analysis/heap/core/report.mjs +68 -0
- package/heap-analysis/heap/core/strings.mjs +80 -0
- package/heap-analysis/heap/langium/concepts.mjs +230 -0
- package/heap-analysis/heap/langium/glsp.classifier.mjs +80 -0
- package/heap-analysis/heap/langium/langium.classifier.mjs +203 -0
- package/heap-analysis/heap/renderer/renderer.mjs +122 -0
- package/lib/cli-version.d.ts +21 -0
- package/lib/cli-version.d.ts.map +1 -0
- package/lib/cli-version.js +30 -0
- package/lib/cli-version.js.map +1 -0
- package/lib/cli.d.ts +11 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +135 -0
- package/lib/cli.js.map +1 -0
- package/lib/commands/analyze-heap-args.d.ts +46 -0
- package/lib/commands/analyze-heap-args.d.ts.map +1 -0
- package/lib/commands/analyze-heap-args.js +174 -0
- package/lib/commands/analyze-heap-args.js.map +1 -0
- package/lib/commands/analyze-heap.d.ts +37 -0
- package/lib/commands/analyze-heap.d.ts.map +1 -0
- package/lib/commands/analyze-heap.js +63 -0
- package/lib/commands/analyze-heap.js.map +1 -0
- package/lib/commands/ast-ground-truth-args.d.ts +23 -0
- package/lib/commands/ast-ground-truth-args.d.ts.map +1 -0
- package/lib/commands/ast-ground-truth-args.js +47 -0
- package/lib/commands/ast-ground-truth-args.js.map +1 -0
- package/lib/commands/ast-ground-truth-driver.d.ts +10 -0
- package/lib/commands/ast-ground-truth-driver.d.ts.map +1 -0
- package/lib/commands/ast-ground-truth-driver.js +72 -0
- package/lib/commands/ast-ground-truth-driver.js.map +1 -0
- package/lib/commands/ast-ground-truth.d.ts +31 -0
- package/lib/commands/ast-ground-truth.d.ts.map +1 -0
- package/lib/commands/ast-ground-truth.js +36 -0
- package/lib/commands/ast-ground-truth.js.map +1 -0
- package/lib/commands/generate-transfer-model-args.d.ts +38 -0
- package/lib/commands/generate-transfer-model-args.d.ts.map +1 -0
- package/lib/commands/generate-transfer-model-args.js +183 -0
- package/lib/commands/generate-transfer-model-args.js.map +1 -0
- package/lib/commands/generate-transfer-model-config.d.ts +32 -0
- package/lib/commands/generate-transfer-model-config.d.ts.map +1 -0
- package/lib/commands/generate-transfer-model-config.js +122 -0
- package/lib/commands/generate-transfer-model-config.js.map +1 -0
- package/lib/commands/generate-transfer-model.d.ts +110 -0
- package/lib/commands/generate-transfer-model.d.ts.map +1 -0
- package/lib/commands/generate-transfer-model.js +587 -0
- package/lib/commands/generate-transfer-model.js.map +1 -0
- package/lib/commands/harness-args.d.ts +198 -0
- package/lib/commands/harness-args.d.ts.map +1 -0
- package/lib/commands/harness-args.js +339 -0
- package/lib/commands/harness-args.js.map +1 -0
- package/lib/commands/headless-harness.d.ts +97 -0
- package/lib/commands/headless-harness.d.ts.map +1 -0
- package/lib/commands/headless-harness.js +121 -0
- package/lib/commands/headless-harness.js.map +1 -0
- package/lib/commands/init-args.d.ts +81 -0
- package/lib/commands/init-args.d.ts.map +1 -0
- package/lib/commands/init-args.js +250 -0
- package/lib/commands/init-args.js.map +1 -0
- package/lib/commands/init-prompt.d.ts +66 -0
- package/lib/commands/init-prompt.d.ts.map +1 -0
- package/lib/commands/init-prompt.js +71 -0
- package/lib/commands/init-prompt.js.map +1 -0
- package/lib/commands/init-templates.d.ts +42 -0
- package/lib/commands/init-templates.d.ts.map +1 -0
- package/lib/commands/init-templates.js +1902 -0
- package/lib/commands/init-templates.js.map +1 -0
- package/lib/commands/init-wizard.d.ts +65 -0
- package/lib/commands/init-wizard.d.ts.map +1 -0
- package/lib/commands/init-wizard.js +302 -0
- package/lib/commands/init-wizard.js.map +1 -0
- package/lib/commands/init-workspace.d.ts +168 -0
- package/lib/commands/init-workspace.d.ts.map +1 -0
- package/lib/commands/init-workspace.js +372 -0
- package/lib/commands/init-workspace.js.map +1 -0
- package/lib/commands/init.d.ts +273 -0
- package/lib/commands/init.d.ts.map +1 -0
- package/lib/commands/init.js +303 -0
- package/lib/commands/init.js.map +1 -0
- package/lib/commands/lint-grammar-args.d.ts +23 -0
- package/lib/commands/lint-grammar-args.d.ts.map +1 -0
- package/lib/commands/lint-grammar-args.js +63 -0
- package/lib/commands/lint-grammar-args.js.map +1 -0
- package/lib/commands/lint-grammar-driver.d.ts +10 -0
- package/lib/commands/lint-grammar-driver.d.ts.map +1 -0
- package/lib/commands/lint-grammar-driver.js +72 -0
- package/lib/commands/lint-grammar-driver.js.map +1 -0
- package/lib/commands/lint-grammar-report.d.ts +24 -0
- package/lib/commands/lint-grammar-report.d.ts.map +1 -0
- package/lib/commands/lint-grammar-report.js +65 -0
- package/lib/commands/lint-grammar-report.js.map +1 -0
- package/lib/commands/lint-grammar.d.ts +33 -0
- package/lib/commands/lint-grammar.d.ts.map +1 -0
- package/lib/commands/lint-grammar.js +42 -0
- package/lib/commands/lint-grammar.js.map +1 -0
- package/lib/commands/measure-memory-args.d.ts +23 -0
- package/lib/commands/measure-memory-args.d.ts.map +1 -0
- package/lib/commands/measure-memory-args.js +106 -0
- package/lib/commands/measure-memory-args.js.map +1 -0
- package/lib/commands/measure-memory-driver.d.ts +10 -0
- package/lib/commands/measure-memory-driver.d.ts.map +1 -0
- package/lib/commands/measure-memory-driver.js +110 -0
- package/lib/commands/measure-memory-driver.js.map +1 -0
- package/lib/commands/measure-memory.d.ts +68 -0
- package/lib/commands/measure-memory.d.ts.map +1 -0
- package/lib/commands/measure-memory.js +100 -0
- package/lib/commands/measure-memory.js.map +1 -0
- package/lib/commands/model-docs-args.d.ts +23 -0
- package/lib/commands/model-docs-args.d.ts.map +1 -0
- package/lib/commands/model-docs-args.js +53 -0
- package/lib/commands/model-docs-args.js.map +1 -0
- package/lib/commands/model-docs-driver.d.ts +10 -0
- package/lib/commands/model-docs-driver.d.ts.map +1 -0
- package/lib/commands/model-docs-driver.js +61 -0
- package/lib/commands/model-docs-driver.js.map +1 -0
- package/lib/commands/model-docs-report.d.ts +18 -0
- package/lib/commands/model-docs-report.d.ts.map +1 -0
- package/lib/commands/model-docs-report.js +123 -0
- package/lib/commands/model-docs-report.js.map +1 -0
- package/lib/commands/model-docs.d.ts +30 -0
- package/lib/commands/model-docs.d.ts.map +1 -0
- package/lib/commands/model-docs.js +37 -0
- package/lib/commands/model-docs.js.map +1 -0
- package/lib/commands/projects-args.d.ts +25 -0
- package/lib/commands/projects-args.d.ts.map +1 -0
- package/lib/commands/projects-args.js +49 -0
- package/lib/commands/projects-args.js.map +1 -0
- package/lib/commands/projects.d.ts +45 -0
- package/lib/commands/projects.d.ts.map +1 -0
- package/lib/commands/projects.js +39 -0
- package/lib/commands/projects.js.map +1 -0
- package/lib/commands/query-args.d.ts +25 -0
- package/lib/commands/query-args.d.ts.map +1 -0
- package/lib/commands/query-args.js +46 -0
- package/lib/commands/query-args.js.map +1 -0
- package/lib/commands/query.d.ts +32 -0
- package/lib/commands/query.d.ts.map +1 -0
- package/lib/commands/query.js +29 -0
- package/lib/commands/query.js.map +1 -0
- package/lib/commands/reflect-args.d.ts +23 -0
- package/lib/commands/reflect-args.d.ts.map +1 -0
- package/lib/commands/reflect-args.js +59 -0
- package/lib/commands/reflect-args.js.map +1 -0
- package/lib/commands/reflect-driver.d.ts +10 -0
- package/lib/commands/reflect-driver.d.ts.map +1 -0
- package/lib/commands/reflect-driver.js +66 -0
- package/lib/commands/reflect-driver.js.map +1 -0
- package/lib/commands/reflect-report.d.ts +20 -0
- package/lib/commands/reflect-report.d.ts.map +1 -0
- package/lib/commands/reflect-report.js +127 -0
- package/lib/commands/reflect-report.js.map +1 -0
- package/lib/commands/reflect.d.ts +30 -0
- package/lib/commands/reflect.d.ts.map +1 -0
- package/lib/commands/reflect.js +38 -0
- package/lib/commands/reflect.js.map +1 -0
- package/lib/commands/save-args.d.ts +26 -0
- package/lib/commands/save-args.d.ts.map +1 -0
- package/lib/commands/save-args.js +59 -0
- package/lib/commands/save-args.js.map +1 -0
- package/lib/commands/save.d.ts +38 -0
- package/lib/commands/save.d.ts.map +1 -0
- package/lib/commands/save.js +48 -0
- package/lib/commands/save.js.map +1 -0
- package/lib/commands/validate-args.d.ts +23 -0
- package/lib/commands/validate-args.d.ts.map +1 -0
- package/lib/commands/validate-args.js +60 -0
- package/lib/commands/validate-args.js.map +1 -0
- package/lib/commands/validate-driver.d.ts +10 -0
- package/lib/commands/validate-driver.d.ts.map +1 -0
- package/lib/commands/validate-driver.js +72 -0
- package/lib/commands/validate-driver.js.map +1 -0
- package/lib/commands/validate-report.d.ts +30 -0
- package/lib/commands/validate-report.d.ts.map +1 -0
- package/lib/commands/validate-report.js +87 -0
- package/lib/commands/validate-report.js.map +1 -0
- package/lib/commands/validate.d.ts +34 -0
- package/lib/commands/validate.d.ts.map +1 -0
- package/lib/commands/validate.js +41 -0
- package/lib/commands/validate.js.map +1 -0
- package/lib/commands/watch-args.d.ts +26 -0
- package/lib/commands/watch-args.d.ts.map +1 -0
- package/lib/commands/watch-args.js +59 -0
- package/lib/commands/watch-args.js.map +1 -0
- package/lib/commands/watch.d.ts +75 -0
- package/lib/commands/watch.d.ts.map +1 -0
- package/lib/commands/watch.js +112 -0
- package/lib/commands/watch.js.map +1 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +16 -0
- package/lib/index.js.map +1 -0
- package/lib/log-level.d.ts +39 -0
- package/lib/log-level.d.ts.map +1 -0
- package/lib/log-level.js +47 -0
- package/lib/log-level.js.map +1 -0
- package/lib/package-name.d.ts +33 -0
- package/lib/package-name.d.ts.map +1 -0
- package/lib/package-name.js +37 -0
- package/lib/package-name.js.map +1 -0
- package/lib/spawn-data-server.d.ts +102 -0
- package/lib/spawn-data-server.d.ts.map +1 -0
- package/lib/spawn-data-server.js +154 -0
- package/lib/spawn-data-server.js.map +1 -0
- package/lib/testing/echo-server.d.ts +11 -0
- package/lib/testing/echo-server.d.ts.map +1 -0
- package/lib/testing/echo-server.js +79 -0
- package/lib/testing/echo-server.js.map +1 -0
- package/package.json +105 -0
- package/src/cli-version.ts +32 -0
- package/src/cli.ts +139 -0
- package/src/commands/analyze-heap-args.ts +184 -0
- package/src/commands/analyze-heap.ts +76 -0
- package/src/commands/ast-ground-truth-args.ts +63 -0
- package/src/commands/ast-ground-truth-driver.ts +81 -0
- package/src/commands/ast-ground-truth.ts +49 -0
- package/src/commands/generate-transfer-model-args.ts +203 -0
- package/src/commands/generate-transfer-model-config.ts +128 -0
- package/src/commands/generate-transfer-model.ts +716 -0
- package/src/commands/harness-args.ts +429 -0
- package/src/commands/headless-harness.ts +165 -0
- package/src/commands/init-args.ts +273 -0
- package/src/commands/init-prompt.ts +149 -0
- package/src/commands/init-templates.ts +2037 -0
- package/src/commands/init-wizard.ts +378 -0
- package/src/commands/init-workspace.ts +442 -0
- package/src/commands/init.ts +554 -0
- package/src/commands/lint-grammar-args.ts +79 -0
- package/src/commands/lint-grammar-driver.ts +83 -0
- package/src/commands/lint-grammar-report.ts +74 -0
- package/src/commands/lint-grammar.ts +57 -0
- package/src/commands/measure-memory-args.ts +123 -0
- package/src/commands/measure-memory-driver.ts +128 -0
- package/src/commands/measure-memory.ts +143 -0
- package/src/commands/model-docs-args.ts +69 -0
- package/src/commands/model-docs-driver.ts +68 -0
- package/src/commands/model-docs-report.ts +133 -0
- package/src/commands/model-docs.ts +48 -0
- package/src/commands/projects-args.ts +62 -0
- package/src/commands/projects.ts +76 -0
- package/src/commands/query-args.ts +61 -0
- package/src/commands/query.ts +58 -0
- package/src/commands/reflect-args.ts +75 -0
- package/src/commands/reflect-driver.ts +76 -0
- package/src/commands/reflect-report.ts +138 -0
- package/src/commands/reflect.ts +51 -0
- package/src/commands/save-args.ts +74 -0
- package/src/commands/save.ts +84 -0
- package/src/commands/validate-args.ts +76 -0
- package/src/commands/validate-driver.ts +84 -0
- package/src/commands/validate-report.ts +95 -0
- package/src/commands/validate.ts +58 -0
- package/src/commands/watch-args.ts +74 -0
- package/src/commands/watch.ts +185 -0
- package/src/index.ts +16 -0
- package/src/log-level.ts +49 -0
- package/src/package-name.ts +39 -0
- package/src/spawn-data-server.ts +247 -0
- package/src/testing/echo-server.ts +93 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* Memory-analysis CLI for Langium/grammar-based language servers.
|
|
12
|
+
*
|
|
13
|
+
* Loads a .heapsnapshot via memlab, classifies every node into a Langium memory
|
|
14
|
+
* CONCEPT, and reports three size views that must never be conflated:
|
|
15
|
+
* - shallow (self_size): the clean additive partition (sums to 100%)
|
|
16
|
+
* - exclusive retained: dominator-attributed, non-overlapping ("what frees if
|
|
17
|
+
* this concept goes away") -- concepts act as ownership anchors
|
|
18
|
+
* - overlapping retained: the DevTools Summary number (never summed)
|
|
19
|
+
* Plus an AST-by-$type breakdown and sample retainer paths to GC root.
|
|
20
|
+
*
|
|
21
|
+
* Spawned by the `analyze-heap` subcommand, which owns the flag list, the help
|
|
22
|
+
* text and the rejection of anything outside it; see `hydranium-cli analyze-heap
|
|
23
|
+
* --help`. Values are read here so the numeric coercions live beside the code
|
|
24
|
+
* that consumes them.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as path from 'node:path';
|
|
28
|
+
import { pathToFileURL } from 'node:url';
|
|
29
|
+
import { aggregateByBucket, attributeExclusiveRetained, shortestRetainerPath } from './heap/core/aggregate.mjs';
|
|
30
|
+
import { holderProfiles, regroupTally } from './heap/core/holders.mjs';
|
|
31
|
+
import { duplicateStrings, rankReclaimable } from './heap/core/strings.mjs';
|
|
32
|
+
import { findCutPoints } from './heap/core/cutpoints.mjs';
|
|
33
|
+
import { diffAnalyses } from './heap/core/diff.mjs';
|
|
34
|
+
import { Report } from './heap/core/report.mjs';
|
|
35
|
+
import { composeClassifiers, buildHierarchy, stringProp } from './heap/langium/concepts.mjs';
|
|
36
|
+
import { rendererClassifiers } from './heap/renderer/renderer.mjs';
|
|
37
|
+
import { mb, humanBytes, pct, progress } from './heap/core/format.mjs';
|
|
38
|
+
|
|
39
|
+
const argv = process.argv.slice(2);
|
|
40
|
+
const flagValue = name => {
|
|
41
|
+
const i = argv.indexOf(name);
|
|
42
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
43
|
+
};
|
|
44
|
+
const flagValues = name => {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (let i = 0; i < argv.length; i++) {
|
|
47
|
+
if (argv[i] === name && argv[i + 1] !== undefined) {
|
|
48
|
+
out.push(argv[i + 1]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// A help-only or empty argv is answered by the subcommand out of its own
|
|
55
|
+
// flag/help data and never forwarded here, so reaching this branch means the
|
|
56
|
+
// analyzer was invoked directly. Point at the surface that documents it rather
|
|
57
|
+
// than keeping a second copy of the help to drift, and fail on a bare invocation
|
|
58
|
+
// the way the parent binary does.
|
|
59
|
+
if (argv.includes('-h') || argv.includes('--help') || argv.length === 0) {
|
|
60
|
+
console.error('Run `hydranium-cli analyze-heap --help` for usage.');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --diff mode: pure JSON-vs-JSON, no snapshot/memlab. Runs first and exits.
|
|
65
|
+
if (argv.includes('--diff')) {
|
|
66
|
+
const di = argv.indexOf('--diff');
|
|
67
|
+
const basePath = argv[di + 1];
|
|
68
|
+
const curPath = argv[di + 2];
|
|
69
|
+
if (!basePath || !curPath) {
|
|
70
|
+
console.error('Usage: --diff <baseline.json> <current.json> [--threshold <pct>]');
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
// `|| 5` would swallow an explicit `--threshold 0` (a 0% gate) back to the
|
|
74
|
+
// default; keep 0 by only falling back on a non-finite value.
|
|
75
|
+
const thresholdRaw = Number(flagValue('--threshold'));
|
|
76
|
+
const thresholdPct = (Number.isFinite(thresholdRaw) ? thresholdRaw : 5) / 100;
|
|
77
|
+
const baseline = JSON.parse(fs.readFileSync(basePath, 'utf8'));
|
|
78
|
+
const current = JSON.parse(fs.readFileSync(curPath, 'utf8'));
|
|
79
|
+
const diff = diffAnalyses(baseline, current, thresholdPct);
|
|
80
|
+
const sign = bytes => (bytes >= 0 ? '+' : '') + mb(bytes);
|
|
81
|
+
console.log(`\n=== Heap diff: ${basePath} -> ${curPath} ===`);
|
|
82
|
+
console.log(
|
|
83
|
+
`Total shallow: ${mb(diff.total.base)} -> ${mb(diff.total.cur)} (${sign(diff.total.delta)}, ${(diff.total.growthPct * 100).toFixed(1)}%)`
|
|
84
|
+
);
|
|
85
|
+
console.log(`\n Concept exclusive-retained deltas:`);
|
|
86
|
+
for (const { key, base: from, cur: to, delta } of diff.concepts.slice(0, 20)) {
|
|
87
|
+
console.log(` ${sign(delta).padStart(11)} ${mb(from).padStart(9)} -> ${mb(to).padStart(9)} ${key}`);
|
|
88
|
+
}
|
|
89
|
+
const astChanged = diff.ast.slice(0, 15);
|
|
90
|
+
if (astChanged.length) {
|
|
91
|
+
console.log(`\n AST $type count deltas:`);
|
|
92
|
+
for (const { key, base: from, cur: to, delta } of astChanged) {
|
|
93
|
+
console.log(` ${(delta >= 0 ? '+' : '') + delta}`.padStart(13) + ` ${from} -> ${to} ${key}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
console.log(
|
|
97
|
+
`\nVerdict: ${diff.regressed ? 'REGRESSED' : 'ok'} (threshold ${(diff.thresholdPct * 100).toFixed(1)}% growth of total shallow)`
|
|
98
|
+
);
|
|
99
|
+
process.exit(diff.regressed ? 1 : 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Drill-downs run by default (loading the snapshot is the only real cost; the
|
|
103
|
+
// analyses are cheap). A flag tunes its top-N; passing 0 disables that section.
|
|
104
|
+
const flagCount = (name, fallback) => {
|
|
105
|
+
const index = argv.indexOf(name);
|
|
106
|
+
if (index < 0) {
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
const value = Number(argv[index + 1]);
|
|
110
|
+
return Number.isFinite(value) ? value : fallback;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const file = argv.find(arg => arg.endsWith('.heapsnapshot'));
|
|
114
|
+
// --holders accepts a comma-separated list (CST,Range,Map), profiled in one
|
|
115
|
+
// heap pass; with no value it falls back to the top model concepts (null here).
|
|
116
|
+
const holdersFlag = argv.includes('--holders');
|
|
117
|
+
const holdersRaw = holdersFlag ? flagValue('--holders') : undefined;
|
|
118
|
+
const holdersList = !holdersFlag
|
|
119
|
+
? []
|
|
120
|
+
: holdersRaw && !holdersRaw.startsWith('-')
|
|
121
|
+
? holdersRaw
|
|
122
|
+
.split(',')
|
|
123
|
+
.map(name => name.trim())
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
: null;
|
|
126
|
+
// Renderer/browser heap mode — swaps the Langium/GLSP classifiers for the DOM
|
|
127
|
+
// vocabulary and drops the Langium-only ground-truth validation.
|
|
128
|
+
const rendererMode = argv.includes('--renderer');
|
|
129
|
+
const validateFile = rendererMode ? undefined : flagValue('--validate');
|
|
130
|
+
const stringsTopN = flagCount('--strings', 20);
|
|
131
|
+
const cutTopN = flagCount('--cutpoints', 10);
|
|
132
|
+
const docsTopN = flagCount('--docs', 15);
|
|
133
|
+
const cutpointsAll = argv.includes('--cutpoints-all');
|
|
134
|
+
const jsonFile = flagValue('--json');
|
|
135
|
+
const outFile = flagValue('--out-file');
|
|
136
|
+
// Max DISPLAY depth of the hierarchical breakdown; Infinity = show full depth.
|
|
137
|
+
// Aggregation is always depth-agnostic, so a cap only elides deeper rows.
|
|
138
|
+
const breakdownDepth = argv.includes('--breakdown-depth') ? flagCount('--breakdown-depth', Infinity) : Infinity;
|
|
139
|
+
// How many of the biggest anchor concepts get a retainer-path sample / are the
|
|
140
|
+
// default --holders targets. Arbitrary readability cap (loading is the real cost).
|
|
141
|
+
const topConceptsN = flagCount('--top-concepts', 6);
|
|
142
|
+
if (!file) {
|
|
143
|
+
// The subcommand already demands a positional; what survives to here is a
|
|
144
|
+
// positional that is not a `.heapsnapshot`, which memlab cannot load.
|
|
145
|
+
console.error('No <snapshot> given. Run `hydranium-cli analyze-heap --help` for usage.');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Concept classifiers, composed in precedence order: a head's own (--classifier)
|
|
150
|
+
// first so they can override, the framework defaults as the base last. A
|
|
151
|
+
// ConceptClassifier may be the module's default export or any named export with a
|
|
152
|
+
// `classify(node)` function.
|
|
153
|
+
const pickClassifier = loaded =>
|
|
154
|
+
loaded.default && typeof loaded.default.classify === 'function'
|
|
155
|
+
? loaded.default
|
|
156
|
+
: Object.values(loaded).find(value => value && typeof value.classify === 'function');
|
|
157
|
+
|
|
158
|
+
// Default classifiers shipped with the framework, loaded by default — but only
|
|
159
|
+
// IFF their module actually loads. A shipped default that can't be imported (a
|
|
160
|
+
// future classifier whose optional runtime isn't installed, or one trimmed from
|
|
161
|
+
// the bundle) is skipped, not fatal; the run degrades to the classifiers present.
|
|
162
|
+
const DEFAULT_CLASSIFIER_MODULES = rendererMode ? [] : ['./heap/langium/langium.classifier.mjs', './heap/langium/glsp.classifier.mjs'];
|
|
163
|
+
const defaultClassifiers = [];
|
|
164
|
+
for (const spec of DEFAULT_CLASSIFIER_MODULES) {
|
|
165
|
+
try {
|
|
166
|
+
const classifier = pickClassifier(await import(new URL(spec, import.meta.url).href));
|
|
167
|
+
if (classifier) {
|
|
168
|
+
defaultClassifiers.push(classifier);
|
|
169
|
+
} else {
|
|
170
|
+
progress(`Default classifier ${spec} exports no ConceptClassifier — skipping.`);
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
progress(`Default classifier ${spec} unavailable — skipping (${error.message}).`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Renderer mode uses a fixed DOM classifier that must intercept native/DOM nodes
|
|
178
|
+
// itself, so adopter --classifier modules cannot be composed in — reject the
|
|
179
|
+
// combination up front instead of loading them (a load failure is fatal) and then
|
|
180
|
+
// silently discarding their classifications.
|
|
181
|
+
if (rendererMode && flagValues('--classifier').length > 0) {
|
|
182
|
+
console.error('--classifier is not supported with --renderer; the renderer uses a fixed DOM classifier.');
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A head adds its own with `--classifier <file>` (repeatable); an explicitly
|
|
187
|
+
// requested classifier that fails to load IS fatal (unlike the optional defaults).
|
|
188
|
+
const adopterClassifiers = [];
|
|
189
|
+
for (const classifierPath of flagValues('--classifier')) {
|
|
190
|
+
const classifier = pickClassifier(await import(pathToFileURL(path.resolve(classifierPath)).href));
|
|
191
|
+
if (!classifier) {
|
|
192
|
+
console.error(`--classifier '${classifierPath}' must export a ConceptClassifier with a classify(node) function.`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
adopterClassifiers.push(classifier);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Adopter classifiers FIRST so a more specific --classifier overrides a default
|
|
199
|
+
// (first-match-wins); the framework defaults are the base, queried last.
|
|
200
|
+
// Renderer mode installs the DOM classifier (it must intercept `native`/DOM nodes
|
|
201
|
+
// itself — the compose engine short-circuits non-`object` nodes to their primitive
|
|
202
|
+
// bucket before contributions run); otherwise compose adopter + Langium/GLSP.
|
|
203
|
+
const { classify, isAnchorConcept, conceptGroup, grammarTypes, describe } = rendererMode
|
|
204
|
+
? rendererClassifiers()
|
|
205
|
+
: composeClassifiers([...adopterClassifiers, ...defaultClassifiers]);
|
|
206
|
+
const classifyNode = node => classify(node);
|
|
207
|
+
|
|
208
|
+
// Dynamic-import the memlab-backed loader only on the snapshot path, so the
|
|
209
|
+
// --diff short-circuit above stays dependency-free (@memlab lives solely behind
|
|
210
|
+
// load.mjs, reached only here). The subcommand's optional-dependency guard is
|
|
211
|
+
// skipped for the same reason and must stay in step with this import.
|
|
212
|
+
const { loadHeap } = await import('./heap/core/load.mjs');
|
|
213
|
+
const { heap, meta } = await loadHeap(file);
|
|
214
|
+
const { byBucket, selfSize, domIndex, labelOfIndex } = aggregateByBucket(heap, classifyNode);
|
|
215
|
+
|
|
216
|
+
const anchors = new Set([...byBucket.keys()].filter(isAnchorConcept));
|
|
217
|
+
const exclusive = attributeExclusiveRetained({ selfSize, domIndex, labelOfIndex }, anchors);
|
|
218
|
+
|
|
219
|
+
// Aggregate every label into a hierarchy keyed by its `:`-delimited path. The L1
|
|
220
|
+
// groups ARE the concept rows; deeper levels drive the breakdown. Each tree
|
|
221
|
+
// node's sizes are the exact sum of its descendants' leaf buckets (the
|
|
222
|
+
// reconciliation invariant), so nothing is special-cased by concept name.
|
|
223
|
+
const hierarchy = buildHierarchy(byBucket, label => exclusive.get(label) ?? 0);
|
|
224
|
+
const concepts = new Map([...hierarchy].map(([group, node]) => [group, node.sizes]));
|
|
225
|
+
|
|
226
|
+
// AST $types are the L2 children of the `AST node` group — the per-$type counts
|
|
227
|
+
// the --json artifact records and --validate cross-checks against the live model.
|
|
228
|
+
const AST_GROUP = 'AST node';
|
|
229
|
+
const astByType = new Map([...(hierarchy.get(AST_GROUP)?.children ?? new Map())].map(([type, node]) => [type, node.sizes]));
|
|
230
|
+
|
|
231
|
+
const total = meta.totalShallowBytes;
|
|
232
|
+
|
|
233
|
+
// --json: write a stable analysis artifact (the baseline for --diff). Concept
|
|
234
|
+
// sizes + AST counts + total are the regression-relevant signals.
|
|
235
|
+
if (jsonFile) {
|
|
236
|
+
const artifact = {
|
|
237
|
+
meta: { snapshot: file, nodeCount: meta.nodeCount, loadSeconds: meta.loadSeconds },
|
|
238
|
+
totalShallowBytes: total,
|
|
239
|
+
concepts: Object.fromEntries(
|
|
240
|
+
[...concepts].map(([label, sizes]) => [label, { count: sizes.count, shallow: sizes.shallow, exclusive: sizes.exclusive }])
|
|
241
|
+
),
|
|
242
|
+
astByType: Object.fromEntries([...astByType].map(([type, sizes]) => [type, sizes.count]))
|
|
243
|
+
};
|
|
244
|
+
fs.writeFileSync(jsonFile, JSON.stringify(artifact, null, 2));
|
|
245
|
+
progress(`Wrote analysis JSON to ${jsonFile}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Concept rows = the L1 groups, sorted by the larger of shallow/exclusive so big
|
|
249
|
+
// rows stay near the top whether a concept is large because it OWNS memory (high
|
|
250
|
+
// exclusive) or because its own bytes are large (high shallow) -- otherwise owned
|
|
251
|
+
// leaf buckets sink misleadingly. The generic `class` group already folds the
|
|
252
|
+
// long tail of unclassified class instances into one row (drilled down in the
|
|
253
|
+
// breakdown below), so there is no separate misc bucket.
|
|
254
|
+
const rowWeight = sizes => Math.max(sizes.shallow, sizes.exclusive);
|
|
255
|
+
const rows = [...concepts].sort((a, b) => rowWeight(b[1]) - rowWeight(a[1]));
|
|
256
|
+
|
|
257
|
+
const report = new Report();
|
|
258
|
+
report.title('Heap memory report');
|
|
259
|
+
report.note(`Snapshot: \`${file}\``);
|
|
260
|
+
report.note(
|
|
261
|
+
`${meta.nodeCount.toLocaleString()} heap nodes, ${meta.totalShallowMb} MB shallow, loaded in ${meta.loadSeconds}s. ` +
|
|
262
|
+
(rendererMode
|
|
263
|
+
? 'This is the **browser/renderer process** (a `browser-heap` CDP snapshot) — bucketed by DOM vocabulary, not Langium concepts.'
|
|
264
|
+
: 'This is the **language-server process only** — no other processes, native/off-heap memory, or browser heap.')
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
report.section('How to read this report');
|
|
268
|
+
report.note(
|
|
269
|
+
'- **Shallow** — an object\'s own bytes. The clean 100% partition of the heap; use it for "share of heap".\n' +
|
|
270
|
+
'- **Exclusive retained** — dominator-attributed: bytes a concept *solely* owns (freed if it goes away). ' +
|
|
271
|
+
'Non-overlapping, also partitions the heap. The truer cost, because it includes owned subtrees.\n' +
|
|
272
|
+
"- **Retained subtree** — the DevTools-style full dominated subtree (sum of each instance's `retainedSize`). " +
|
|
273
|
+
'It **overlaps** (can exceed the heap total) — never sum it; it is for cross-referencing DevTools and cut points.\n' +
|
|
274
|
+
'- **Concept vs V8 bucket** — concepts are ' +
|
|
275
|
+
(rendererMode ? 'DOM (DOM node, Detached DOM, Event listener)' : 'Langium/model (CST, descriptions, Range, …)') +
|
|
276
|
+
'; `string`, `array (backing)`, `boxed number`, `code` are V8 storage/runtime that mostly *hold* the ' +
|
|
277
|
+
'concept data, so their bytes are attributed to the owning concept (hence ~0 exclusive).\n' +
|
|
278
|
+
'- **Dominator vs referrer** (holders) — the dominator is the *immediate* exclusive owner; a generic container ' +
|
|
279
|
+
'(`Array`/`Map`) shown as a dominator usually belongs to a higher concept, so follow it up. Referrers are ' +
|
|
280
|
+
'*all* pointers (shared included), so high multiplicity means cutting one frees little.\n' +
|
|
281
|
+
'- Sizes are binary MB. Element kinds/`$type`s are read from the heap, so the report needs no source to produce ' +
|
|
282
|
+
'(use `--validate` to check them against the live model).'
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Summary / heap budget: the top concepts by EXCLUSIVE retained (the act-on
|
|
286
|
+
// metric — what frees if the concept goes away) and the single biggest leaf, so a
|
|
287
|
+
// reader sees the headline before the detail. Computed from the same numbers the
|
|
288
|
+
// tables below show, in their own (exclusive-sorted) order.
|
|
289
|
+
const byExclusive = [...concepts].sort((a, b) => b[1].exclusive - a[1].exclusive).filter(([, sizes]) => sizes.exclusive > 0);
|
|
290
|
+
const topExclusive = byExclusive.slice(0, 5);
|
|
291
|
+
const top5Sum = topExclusive.reduce((sum, [, sizes]) => sum + sizes.exclusive, 0);
|
|
292
|
+
const leaves = [];
|
|
293
|
+
const collectLeaves = (path, node) => {
|
|
294
|
+
if (node.children.size === 0) {
|
|
295
|
+
leaves.push([path, node.sizes]);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
for (const [segment, child] of node.children) {
|
|
299
|
+
collectLeaves(path ? `${path} > ${segment}` : path, child);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
for (const [group, node] of hierarchy) {
|
|
303
|
+
collectLeaves(group, node);
|
|
304
|
+
}
|
|
305
|
+
const biggestLeaf = leaves.sort((a, b) => b[1].exclusive - a[1].exclusive)[0];
|
|
306
|
+
|
|
307
|
+
report.section('Summary');
|
|
308
|
+
report.note(
|
|
309
|
+
'Heap budget — the concepts that own the most memory (**exclusive retained**: what frees if removed). ' +
|
|
310
|
+
'Act on this column; `Shallow` and `Retained subtree` in the Concepts table are for cross-referencing only.'
|
|
311
|
+
);
|
|
312
|
+
report.table(
|
|
313
|
+
['Concept', 'Exclusive retained', '% of heap'],
|
|
314
|
+
topExclusive.map(([label, sizes]) => [label, humanBytes(sizes.exclusive), pct(sizes.exclusive, total)])
|
|
315
|
+
);
|
|
316
|
+
report.note(
|
|
317
|
+
`These ${topExclusive.length} account for **${humanBytes(top5Sum)} (${pct(top5Sum, total)})** of the ${meta.totalShallowMb} MB heap.` +
|
|
318
|
+
(biggestLeaf
|
|
319
|
+
? ` The single biggest consumer is **${biggestLeaf[0]}** — ${humanBytes(biggestLeaf[1].exclusive)} across ` +
|
|
320
|
+
`${biggestLeaf[1].count.toLocaleString()} objects; start there. The hierarchical breakdown shows where it sits; ` +
|
|
321
|
+
`Duplicate strings / Cut points / Holders show the levers.`
|
|
322
|
+
: '')
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
report.section('Concepts');
|
|
326
|
+
report.note(
|
|
327
|
+
'Every heap node classified into a memory concept. **Shallow** (own bytes) is where bytes physically ' +
|
|
328
|
+
'are — a clean 100% partition; use it for share of heap. **Exclusive retained** is dominator-attributed: ' +
|
|
329
|
+
'who *owns* each byte (what frees if the concept goes away). The two differ because ownership flows up: a ' +
|
|
330
|
+
'string owned by a richer object is counted under that owner, not under `string`. So a concept can ' +
|
|
331
|
+
'have tiny shallow yet large retained (it solely owns a big subtree), and a leaf bucket owned by ' +
|
|
332
|
+
'others (`string`, `array (backing)`, `boxed number`) has large shallow yet ~0 exclusive. That is attribution, ' +
|
|
333
|
+
'not a violation of the per-node `retained ≥ shallow` rule. **Retained subtree** is the DevTools-style ' +
|
|
334
|
+
"overlapping retained (sum of each instance's full dominated subtree) — it double-counts nested same-concept " +
|
|
335
|
+
'nodes and shared sub-concepts, so **never sum this column**; it is shown to relate to DevTools and to the cut ' +
|
|
336
|
+
"points (for a single-instance concept it equals that node's cut-point subtree). Rows are " +
|
|
337
|
+
'sorted by the larger of shallow and exclusive.'
|
|
338
|
+
);
|
|
339
|
+
report.table(
|
|
340
|
+
['Concept', 'Shallow', 'Shallow %', 'Exclusive retained', 'Excl %', 'Retained subtree', 'Count'],
|
|
341
|
+
rows.map(([label, sizes]) => [
|
|
342
|
+
label,
|
|
343
|
+
humanBytes(sizes.shallow),
|
|
344
|
+
pct(sizes.shallow, total),
|
|
345
|
+
humanBytes(sizes.exclusive),
|
|
346
|
+
pct(sizes.exclusive, total),
|
|
347
|
+
humanBytes(sizes.overlapping),
|
|
348
|
+
sizes.count ? sizes.count.toLocaleString() : ''
|
|
349
|
+
])
|
|
350
|
+
);
|
|
351
|
+
report.note(
|
|
352
|
+
rendererMode
|
|
353
|
+
? 'The renderer heap is bucketed by DOM vocabulary + V8/JS universals:\n' +
|
|
354
|
+
'- **DOM node** / **Detached DOM** — live vs off-tree DOM, grouped by element class (`<div class=…>`, `Text`, ' +
|
|
355
|
+
'`SVG*Element`, …). Detached is the leak signal; its retainer path names the JS holder (a listener/closure/array).\n' +
|
|
356
|
+
'- **V8 native** — V8-internal native allocations, NOT DOM: `ExternalStringData` (externalized string bytes) and ' +
|
|
357
|
+
'`JSArrayBufferData` (ArrayBuffer/typed-array/wasm backing stores). Often the largest concept in a renderer heap.\n' +
|
|
358
|
+
'- **Event listener** — object-typed `V8EventListener`/`EventListener` (native ones surface under DOM node / ' +
|
|
359
|
+
'Detached DOM by their `V8EventListener` class instead).\n' +
|
|
360
|
+
'- **string** / **array (backing)** / **code** / **closure** / **hidden** / **(object shape)** — JS/V8 storage ' +
|
|
361
|
+
'and runtime; large *shallow* but ~0 *exclusive* because their bytes are attributed to the object that owns them.\n' +
|
|
362
|
+
'- **class** — class instances grouped by constructor (widgets, models, caches, ' +
|
|
363
|
+
'framework objects); anchored, so the breakdown ranks which constructor retains the most ' +
|
|
364
|
+
'(a large single-instance row like a leaked widget is the lever).\n' +
|
|
365
|
+
'- **synthetic (roots)** — the GC-root node(s).'
|
|
366
|
+
: 'Many rows are **V8 / system buckets**, not model concepts — they show up as large *shallow* but ~0 *exclusive* ' +
|
|
367
|
+
'because their bytes are attributed to whatever model concept owns them:\n' +
|
|
368
|
+
"- **string** — JS strings (names, `$type` tags, values, each file's source text).\n" +
|
|
369
|
+
"- **array (backing)** — V8's *anonymous* internal element/property arrays behind objects, `Array`s and `Map`s. " +
|
|
370
|
+
'It has no identity of its own; what each array *is* comes from its referrer (the property stores of ' +
|
|
371
|
+
'dictionary-mode descriptions, the scope-index `Map` tables, CST child arrays). The Holders section below ' +
|
|
372
|
+
'breaks down which fields back them. (Distinct from `class:Array`, which is the JS `Array` *object* itself.)\n' +
|
|
373
|
+
'- **empty object {}** — plain objects with no own heap properties (e.g. GLSP layout/option bags whose numbers ' +
|
|
374
|
+
'are inline). Most former members were really `Position`s and are now classified as such.\n' +
|
|
375
|
+
'- **boxed number** — heap `Number`s; mostly V8-internal (offsets/lines are inline SMIs, not these).\n' +
|
|
376
|
+
'- **code** / **hidden** / **(object shape)** / **closure** — compiled code, hidden classes and engine internals.\n' +
|
|
377
|
+
'- **synthetic (roots)** — the synthetic GC-root node(s); appears as a dominator for anything held only from a ' +
|
|
378
|
+
'root (not exclusively owned by a model concept).\n' +
|
|
379
|
+
'- **class** — the catch-all for class instances no classifier claimed (Node/V8 internals, libraries); a single ' +
|
|
380
|
+
'honest row, not drilled down. **Map** / **MultiMap** are generic containers (scope index, caches, the LSP ' +
|
|
381
|
+
'request map) — `--holders Map` reveals which are which.\n' +
|
|
382
|
+
'"dictionary-mode" = V8 fell back to a hash map for an object\'s properties (more bytes per object).'
|
|
383
|
+
);
|
|
384
|
+
// No hardcoded "CST family" rollup: satellites (Range/Position/Segment) have
|
|
385
|
+
// multiple owners, so family attribution must come from dominator data
|
|
386
|
+
// (see `--holders <concept>`), not a guessed concept list.
|
|
387
|
+
const exTotal = [...exclusive.values()].reduce((sum, value) => sum + value, 0);
|
|
388
|
+
report.note(
|
|
389
|
+
`Exclusive attribution sums to ${mb(exTotal)} (== shallow total ${meta.totalShallowMb} MB), confirming a clean partition. ` +
|
|
390
|
+
'Note a **Retained subtree** larger than the whole heap (e.g. `string (concatenated)`) is expected, not a bug: ' +
|
|
391
|
+
"rope/cons-strings share their pieces, so summing each one's dominated subtree double-counts the shared parts — " +
|
|
392
|
+
'which is exactly why that column must never be summed.'
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// Hierarchical breakdown: one generic renderer for every concept group that has
|
|
396
|
+
// sub-levels (AST node -> $type, AstNodeDescription -> layer -> element kind,
|
|
397
|
+
// GModel -> node/edge/... -> subtype, class -> class name). No concept is named
|
|
398
|
+
// here -- the tree shape comes entirely from the `:`-delimited labels, and each
|
|
399
|
+
// parent row is exactly the sum of its children (the reconciliation invariant).
|
|
400
|
+
const TOP_PER_LEVEL = 25;
|
|
401
|
+
const sortChildren = children => [...children].sort((a, b) => rowWeight(b[1].sizes) - rowWeight(a[1].sizes));
|
|
402
|
+
const sumSizes = entries =>
|
|
403
|
+
entries.reduce(
|
|
404
|
+
(acc, [, node]) => ({
|
|
405
|
+
count: acc.count + node.sizes.count,
|
|
406
|
+
shallow: acc.shallow + node.sizes.shallow,
|
|
407
|
+
exclusive: acc.exclusive + node.sizes.exclusive
|
|
408
|
+
}),
|
|
409
|
+
{ count: 0, shallow: 0, exclusive: 0 }
|
|
410
|
+
);
|
|
411
|
+
// Pre-order emit under `node` at relative path `rel` (segments BELOW the group;
|
|
412
|
+
// empty at the group root). Each row's label is the path relative to the group
|
|
413
|
+
// heading, joined by ` > `. A `(no subtype)` row carries any items whose type ends
|
|
414
|
+
// at this level — e.g. elements typed exactly `label` shown beside `label > entity`
|
|
415
|
+
// — so the visible rows still sum to the parent. Recurses to the display-depth cap
|
|
416
|
+
// (level = rel.length + 1); bytes beyond it roll up into the deepest shown row.
|
|
417
|
+
const emitBreakdownRows = (rel, node, out) => {
|
|
418
|
+
if (rel.length + 2 > breakdownDepth) {
|
|
419
|
+
return; // node's children would exceed the display depth
|
|
420
|
+
}
|
|
421
|
+
const entries = sortChildren(node.children);
|
|
422
|
+
if (!entries.length) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const here = rel.join(' > ');
|
|
426
|
+
const self = {
|
|
427
|
+
count: node.sizes.count - sumSizes(entries).count,
|
|
428
|
+
shallow: node.sizes.shallow - sumSizes(entries).shallow,
|
|
429
|
+
exclusive: node.sizes.exclusive - sumSizes(entries).exclusive
|
|
430
|
+
};
|
|
431
|
+
if (self.count > 0) {
|
|
432
|
+
out.push([here ? `${here} (no subtype)` : '(no subtype)', self]);
|
|
433
|
+
}
|
|
434
|
+
for (const [segment, child] of entries.slice(0, TOP_PER_LEVEL)) {
|
|
435
|
+
const childRel = [...rel, segment];
|
|
436
|
+
out.push([childRel.join(' > '), child.sizes]);
|
|
437
|
+
emitBreakdownRows(childRel, child, out);
|
|
438
|
+
}
|
|
439
|
+
const tail = entries.slice(TOP_PER_LEVEL);
|
|
440
|
+
if (tail.length) {
|
|
441
|
+
out.push([`${here ? here + ' > ' : ''}… ${tail.length} more`, sumSizes(tail)]);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
// Drill down only MODEL concepts — those a classifier declared an anchor. That is
|
|
445
|
+
// how a concept opts into the breakdown: the generic `class` fallback (and other
|
|
446
|
+
// V8/engine buckets) is not an anchor, so its long per-name tail of runtime/library
|
|
447
|
+
// classes stays one honest row in the Concepts table without a noisy drill-down.
|
|
448
|
+
const groupsWithChildren = [...hierarchy]
|
|
449
|
+
.filter(([group, node]) => node.children.size > 0 && isAnchorConcept(group))
|
|
450
|
+
.sort((a, b) => rowWeight(b[1].sizes) - rowWeight(a[1].sizes));
|
|
451
|
+
|
|
452
|
+
report.section('Hierarchical breakdown');
|
|
453
|
+
report.note(
|
|
454
|
+
'Each anchor concept drilled into its sub-levels. A label like ' +
|
|
455
|
+
(rendererMode ? '`DOM node:td.item-row` or `V8 native:JSArrayBufferData`' : '`AST node:<$type>` or `GModel:<kind>:<subtype>`') +
|
|
456
|
+
' is a hierarchy path; the analyzer aggregates at EVERY prefix, so the ' +
|
|
457
|
+
'level-1 rows sum to the group total in each heading (counts and bytes reconcile). Rows show the ' +
|
|
458
|
+
'path **relative to the group** (` > ` separates levels); a `(no subtype)` row holds items whose ' +
|
|
459
|
+
'type ends at that level. Only anchor concepts with sub-levels appear here; flat concepts ' +
|
|
460
|
+
'and the `class` fallback (unclassified V8/runtime/library instances) stay single rows in the ' +
|
|
461
|
+
`Concepts table above. Each level shows its top ${TOP_PER_LEVEL} by weight (a trailing \`… N more\` ` +
|
|
462
|
+
'row folds the tail).' +
|
|
463
|
+
(Number.isFinite(breakdownDepth)
|
|
464
|
+
? ` Display capped at depth ${breakdownDepth} via \`--breakdown-depth\`; deeper bytes roll up into the deepest shown row.`
|
|
465
|
+
: '')
|
|
466
|
+
);
|
|
467
|
+
for (const [group, node] of groupsWithChildren) {
|
|
468
|
+
const out = [];
|
|
469
|
+
emitBreakdownRows([], node, out);
|
|
470
|
+
const description = describe(group);
|
|
471
|
+
report.note(
|
|
472
|
+
`**${group}** — ${node.sizes.count.toLocaleString()} nodes, ${humanBytes(node.sizes.shallow)} shallow, ` +
|
|
473
|
+
`${humanBytes(node.sizes.exclusive)} exclusive retained.` +
|
|
474
|
+
(description ? `\n\n${description}` : '')
|
|
475
|
+
);
|
|
476
|
+
// Lead with the group total (italic) so the level-1 rows below visibly sum to it.
|
|
477
|
+
const totalRow = ['*Total*', node.sizes];
|
|
478
|
+
report.table(
|
|
479
|
+
['Path (under ' + group + ')', 'Count', 'Shallow', 'Exclusive retained'],
|
|
480
|
+
[totalRow, ...out].map(([label, sizes]) => [
|
|
481
|
+
label,
|
|
482
|
+
sizes.count.toLocaleString(),
|
|
483
|
+
humanBytes(sizes.shallow),
|
|
484
|
+
humanBytes(sizes.exclusive)
|
|
485
|
+
])
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (validateFile) {
|
|
490
|
+
const groundTruth = JSON.parse(fs.readFileSync(validateFile, 'utf8'));
|
|
491
|
+
const gtByType = groundTruth.byType ?? {};
|
|
492
|
+
const snapDocs = concepts.get('LangiumDocument')?.count ?? 0;
|
|
493
|
+
const types = new Set([...astByType.keys(), ...Object.keys(gtByType)]);
|
|
494
|
+
const matched = [];
|
|
495
|
+
const grammarOnly = [];
|
|
496
|
+
const snapshotOnly = []; // not grammar, not in live model -> computed/derived
|
|
497
|
+
const liveOnly = [];
|
|
498
|
+
for (const type of types) {
|
|
499
|
+
const snap = astByType.get(type)?.count ?? 0;
|
|
500
|
+
const live = gtByType[type] ?? 0;
|
|
501
|
+
if (snap > 0 && live > 0) {
|
|
502
|
+
matched.push([type, snap, live, snap - live]);
|
|
503
|
+
} else if (snap > 0) {
|
|
504
|
+
(grammarTypes.has(type) ? grammarOnly : snapshotOnly).push([type, snap]);
|
|
505
|
+
} else {
|
|
506
|
+
liveOnly.push([type, live]);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
report.section('Validation vs live model');
|
|
510
|
+
report.note(
|
|
511
|
+
`Validates the snapshot's classification against the live model (ground truth from ` +
|
|
512
|
+
`\`hydranium-cli ast-ground-truth\`). Snapshot: ${snapDocs} LangiumDocuments. Live model: ` +
|
|
513
|
+
`${groundTruth.documents} docs, ${groundTruth.totalAstNodes} AST nodes. Counts should match; ` +
|
|
514
|
+
'mismatches are either misclassification or computed/derived types that exist only in the heap.'
|
|
515
|
+
);
|
|
516
|
+
if (Math.abs(snapDocs - (groundTruth.documents ?? 0)) > Math.max(2, snapDocs * 0.05)) {
|
|
517
|
+
report.note(
|
|
518
|
+
`> **WARNING:** the snapshot (${snapDocs} docs) and the ground truth (${groundTruth.documents} docs) ` +
|
|
519
|
+
'look like DIFFERENT workspaces. Validation only works when the ground truth is generated from the ' +
|
|
520
|
+
'SAME workspace the snapshot was captured on; otherwise every count diverges and the deltas below ' +
|
|
521
|
+
"are meaningless. Re-run `hydranium-cli ast-ground-truth` on the snapshot's own workspace."
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
report.note('**Matched model types** (delta should be ~0; `CHECK` means |delta| > 2):');
|
|
525
|
+
report.table(
|
|
526
|
+
['$type', 'Snapshot', 'Live', 'Delta', 'Flag'],
|
|
527
|
+
matched
|
|
528
|
+
.sort((a, b) => b[1] - a[1])
|
|
529
|
+
.map(([type, snap, live, delta]) => [
|
|
530
|
+
type,
|
|
531
|
+
snap.toLocaleString(),
|
|
532
|
+
live.toLocaleString(),
|
|
533
|
+
delta,
|
|
534
|
+
Math.abs(delta) <= 2 ? 'ok' : 'CHECK'
|
|
535
|
+
])
|
|
536
|
+
);
|
|
537
|
+
if (snapshotOnly.length) {
|
|
538
|
+
report.note('**Snapshot-only, not Langium grammar** — computed/derived types (in the heap, not the parsed model); investigate:');
|
|
539
|
+
report.table(
|
|
540
|
+
['$type', 'Count'],
|
|
541
|
+
snapshotOnly.sort((a, b) => b[1] - a[1]).map(([type, snap]) => [type, snap.toLocaleString()])
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (grammarOnly.length) {
|
|
545
|
+
const grammarNodes = grammarOnly.reduce((sum, [, count]) => sum + count, 0);
|
|
546
|
+
report.note(
|
|
547
|
+
`**Snapshot-only Langium grammar AST** (expected, set aside): ${grammarOnly.length} types, ${grammarNodes.toLocaleString()} nodes.`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (liveOnly.length) {
|
|
551
|
+
report.note('**Live-model-only** (in the model, not classified in the snapshot):');
|
|
552
|
+
report.table(
|
|
553
|
+
['$type', 'Count'],
|
|
554
|
+
liveOnly.sort((a, b) => b[1] - a[1]).map(([type, live]) => [type, live.toLocaleString()])
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
const drifted = matched.filter(([, , , delta]) => Math.abs(delta) > 2).length;
|
|
558
|
+
report.note(
|
|
559
|
+
!drifted && !snapshotOnly.length && !liveOnly.length
|
|
560
|
+
? '**Verdict: OK** — every model `$type` reconciles with the live model (no discrepancies).'
|
|
561
|
+
: `**Verdict:** ${drifted} drifted, ${snapshotOnly.length} snapshot-only (computed/derived), ${liveOnly.length} live-only — see above.`
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (stringsTopN) {
|
|
566
|
+
const { byValue, totalBytes, totalNodes, skippedLong } = duplicateStrings(heap);
|
|
567
|
+
const { dups, reclaimableTotal, duplicatedValues, distinctValues } = rankReclaimable(byValue);
|
|
568
|
+
report.section('Duplicate strings (interning lever)');
|
|
569
|
+
report.note(
|
|
570
|
+
`${totalNodes.toLocaleString()} plain string nodes, ${mb(totalBytes)} (${skippedLong.toLocaleString()} long values ` +
|
|
571
|
+
`skipped as non-candidates). ${distinctValues.toLocaleString()} distinct values, ${duplicatedValues.toLocaleString()} ` +
|
|
572
|
+
`duplicated. V8 does not intern these, so the same value exists as many separate nodes; interning each to one copy ` +
|
|
573
|
+
`would reclaim **${mb(reclaimableTotal)}** (${pct(reclaimableTotal, totalBytes)} of string bytes). ` +
|
|
574
|
+
'(Concatenated strings are a separate compute-on-demand lever; memlab does not expose their value.)'
|
|
575
|
+
);
|
|
576
|
+
report.table(
|
|
577
|
+
['Value', 'Count', 'Per instance', 'Reclaimable'],
|
|
578
|
+
dups.slice(0, stringsTopN).map(({ value, count, size, reclaimable }) => {
|
|
579
|
+
const shown = value.length > 60 ? value.slice(0, 57) + '…' : value;
|
|
580
|
+
return ['`' + shown.replace(/`/g, '') + '`', count.toLocaleString(), `${size} B`, mb(reclaimable)];
|
|
581
|
+
})
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (cutTopN) {
|
|
586
|
+
// Runtime/engine sole-owners (Node internals, the LSP server's connection
|
|
587
|
+
// context, backing arrays) dominate the raw list but are not model memory.
|
|
588
|
+
// Filter them out by default so the model cut points stand out; the model is
|
|
589
|
+
// a densely co-owned graph, so there are usually very few.
|
|
590
|
+
const isRuntimeLabel = label =>
|
|
591
|
+
label.startsWith('class:') ||
|
|
592
|
+
label.startsWith('(') ||
|
|
593
|
+
[
|
|
594
|
+
'closure',
|
|
595
|
+
'code',
|
|
596
|
+
'native',
|
|
597
|
+
'hidden',
|
|
598
|
+
'synthetic (roots)',
|
|
599
|
+
'array (backing)',
|
|
600
|
+
'boxed number',
|
|
601
|
+
'empty object {}',
|
|
602
|
+
'other Object shape',
|
|
603
|
+
'string',
|
|
604
|
+
'string (concatenated)',
|
|
605
|
+
'string (sliced)'
|
|
606
|
+
].includes(label);
|
|
607
|
+
const raw = findCutPoints(heap, labelOfIndex, { topN: cutpointsAll ? cutTopN : 500 });
|
|
608
|
+
const cuts = cutpointsAll ? raw : raw.filter(cut => !isRuntimeLabel(cut.label)).slice(0, cutTopN);
|
|
609
|
+
report.section('Cut points: lightweight sole owners');
|
|
610
|
+
report.note(
|
|
611
|
+
'Nodes with tiny shallow but huge retained size: each is the sole owner of a large subtree, so severing ' +
|
|
612
|
+
'the few references to it frees that whole subtree. `[severable]` marks ≤ 2 referrers. ' +
|
|
613
|
+
(cutpointsAll
|
|
614
|
+
? 'Showing all owners including Node/V8/server runtime.'
|
|
615
|
+
: 'Showing **model only** (Node/V8/server runtime filtered out — use `--cutpoints-all` to include them). ' +
|
|
616
|
+
'The model is a densely co-owned graph, so there are usually very few model cut points.')
|
|
617
|
+
);
|
|
618
|
+
report.note(
|
|
619
|
+
'**Retained subtree** here is the raw dominated subtree (full `retainedSize`) of one node, so it ' +
|
|
620
|
+
"*overlaps* the Concepts table — a single owning node's subtree includes the child objects the " +
|
|
621
|
+
'Concepts table credits to their own buckets. It answers "what frees if I sever this ' +
|
|
622
|
+
'node", which is larger than that concept\'s partitioned exclusive retained.'
|
|
623
|
+
);
|
|
624
|
+
report.table(
|
|
625
|
+
['Concept', 'Held via (Holder.field)', 'Retained subtree', 'Shallow', 'Referrers', 'Severable'],
|
|
626
|
+
cuts.map(({ label, retainer, shallow, retained, referrers }) => [
|
|
627
|
+
label,
|
|
628
|
+
retainer ? '`' + retainer + '`' : '',
|
|
629
|
+
humanBytes(retained),
|
|
630
|
+
humanBytes(shallow),
|
|
631
|
+
String(referrers),
|
|
632
|
+
referrers <= 2 ? 'yes' : ''
|
|
633
|
+
])
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Per-document attribution is Langium-only (keys off `LangiumDocument` heap nodes);
|
|
638
|
+
// a renderer heap has no documents, so the section is skipped in `--renderer` mode.
|
|
639
|
+
if (docsTopN && !rendererMode) {
|
|
640
|
+
const docs = [];
|
|
641
|
+
heap.nodes.forEach(node => {
|
|
642
|
+
if (conceptGroup(classifyNode(node)) !== 'LangiumDocument') {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
let uri = stringProp(node, 'uri');
|
|
646
|
+
if (!uri) {
|
|
647
|
+
for (const edge of node.references) {
|
|
648
|
+
if (edge.type === 'property' && edge.name_or_index === 'uri' && edge.toNode) {
|
|
649
|
+
uri = stringProp(edge.toNode, 'path') || stringProp(edge.toNode, 'fsPath');
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
docs.push({ uri: uri || `#${node.id}`, retained: node.retainedSize });
|
|
655
|
+
});
|
|
656
|
+
docs.sort((a, b) => b.retained - a.retained);
|
|
657
|
+
const shownDocs = docs.slice(0, docsTopN);
|
|
658
|
+
// Strip the shared directory prefix so the meaningful filename is readable;
|
|
659
|
+
// state the prefix once instead of repeating it on every row.
|
|
660
|
+
const pathUris = shownDocs.map(doc => doc.uri).filter(uri => uri.includes('/'));
|
|
661
|
+
let commonPrefix = '';
|
|
662
|
+
if (pathUris.length > 1) {
|
|
663
|
+
commonPrefix = pathUris.reduce((left, right) => {
|
|
664
|
+
let i = 0;
|
|
665
|
+
while (i < left.length && i < right.length && left[i] === right[i]) {
|
|
666
|
+
i++;
|
|
667
|
+
}
|
|
668
|
+
return left.slice(0, i);
|
|
669
|
+
});
|
|
670
|
+
commonPrefix = commonPrefix.slice(0, commonPrefix.lastIndexOf('/') + 1);
|
|
671
|
+
}
|
|
672
|
+
const relativeUri = uri => (commonPrefix && uri.startsWith(commonPrefix) ? uri.slice(commonPrefix.length) : uri);
|
|
673
|
+
report.section('Heaviest documents by exclusively-owned memory');
|
|
674
|
+
report.note(
|
|
675
|
+
`Top ${docsTopN} files by the memory their document node exclusively retains. Each figure is the file's own ` +
|
|
676
|
+
'parse artifacts (ParseResult → AST → CST → ranges). Its descriptions are **excluded** because the scope ' +
|
|
677
|
+
"index co-owns them, so this undercounts the file's full footprint and is best read as a ranking, not an absolute." +
|
|
678
|
+
(commonPrefix ? `\n\nPaths are relative to \`${commonPrefix}\`.` : '')
|
|
679
|
+
);
|
|
680
|
+
report.table(
|
|
681
|
+
['Exclusively owned', 'Document'],
|
|
682
|
+
shownDocs.map(({ uri, retained }) => [humanBytes(retained), relativeUri(uri)])
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Sample shortest retainer path for the biggest anchor concepts: pick, per
|
|
687
|
+
// concept, the node with the largest retained size, then trace it to GC root.
|
|
688
|
+
const topConcepts = rows
|
|
689
|
+
.filter(([label]) => isAnchorConcept(label))
|
|
690
|
+
.slice(0, topConceptsN)
|
|
691
|
+
.map(([label]) => label);
|
|
692
|
+
const conceptOf = node => conceptGroup(classifyNode(node));
|
|
693
|
+
const best = new Map(); // concept -> { node, retained }
|
|
694
|
+
heap.nodes.forEach(node => {
|
|
695
|
+
const label = conceptOf(node);
|
|
696
|
+
if (!topConcepts.includes(label)) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const cur = best.get(label);
|
|
700
|
+
if (!cur || node.retainedSize > cur.retained) {
|
|
701
|
+
best.set(label, { node, retained: node.retainedSize });
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
// Backing-store and engine-internal holders are pure plumbing; eliding them
|
|
705
|
+
// leaves the meaningful "Holder.edge" links that tell the ownership story.
|
|
706
|
+
const INTERNAL_HOLDERS = new Set(['(array)', 'Array', '(hidden)']);
|
|
707
|
+
const formatHop = hop => (hop.via.startsWith('[') ? `${hop.from}${hop.via}` : `${hop.from}.${hop.via}`);
|
|
708
|
+
report.section('Sample retainer paths to GC root');
|
|
709
|
+
report.note(
|
|
710
|
+
'For the largest node of each top concept, the shortest path to a GC root, one hop per line: each ' +
|
|
711
|
+
'`← Holder.edge` reads "held by Holder via edge", chaining down to the root. Array/hidden ' +
|
|
712
|
+
'backing-store hops are elided so the meaningful links show. These reveal *why* a concept is held ' +
|
|
713
|
+
'(e.g. everything bottoming out at the index manager means closing a file frees little).'
|
|
714
|
+
);
|
|
715
|
+
const pathBlocks = [];
|
|
716
|
+
for (const label of topConcepts) {
|
|
717
|
+
const hit = best.get(label);
|
|
718
|
+
if (!hit) {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const hops = shortestRetainerPath(hit.node).filter(hop => !INTERNAL_HOLDERS.has(hop.from));
|
|
722
|
+
const conceptExclusive = concepts.get(label)?.exclusive ?? 0;
|
|
723
|
+
const lines = [`${label} (concept owns ${humanBytes(conceptExclusive)}; one representative node)`];
|
|
724
|
+
for (const hop of hops) {
|
|
725
|
+
lines.push(` ← ${formatHop(hop)}`);
|
|
726
|
+
}
|
|
727
|
+
pathBlocks.push(lines.join('\n'));
|
|
728
|
+
}
|
|
729
|
+
report.code(pathBlocks.join('\n\n'));
|
|
730
|
+
|
|
731
|
+
// --holders: who holds the nodes of each requested concept? Two views per
|
|
732
|
+
// concept -- the exclusive owner (dominator) and all referrers -- profiled in
|
|
733
|
+
// one heap pass. With no value, default to the top model concepts.
|
|
734
|
+
if (holdersFlag) {
|
|
735
|
+
// Default targets: the top model concepts plus `array (backing)` — it is ~0
|
|
736
|
+
// exclusive (so never a top concept) yet often the largest SHALLOW bucket, and
|
|
737
|
+
// its holders reveal which fields the anonymous arrays actually back.
|
|
738
|
+
const defaultTargets = [...topConcepts];
|
|
739
|
+
if (concepts.has('array (backing)') && !defaultTargets.includes('array (backing)')) {
|
|
740
|
+
defaultTargets.push('array (backing)');
|
|
741
|
+
}
|
|
742
|
+
const targets = holdersList ?? defaultTargets;
|
|
743
|
+
const profiles = holderProfiles(heap, labelOfIndex, domIndex, new Set(targets), conceptGroup);
|
|
744
|
+
for (const group of targets) {
|
|
745
|
+
const { targetCount, targetRetained, dominators, referrers } = profiles.get(group) ?? {};
|
|
746
|
+
report.section(`Holders of "${group}"`);
|
|
747
|
+
if (!targetCount) {
|
|
748
|
+
report.note(`No nodes classified as "${group}" — check the concept name against the Concepts table.`);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
report.note(
|
|
752
|
+
`${targetCount.toLocaleString()} nodes, ${humanBytes(targetRetained)} retained subtree. **Dominator** = who ` +
|
|
753
|
+
'exclusively owns each node (cut one, it frees). **Referrers** = everyone pointing at them, including ' +
|
|
754
|
+
'shared refs (`__proto__` elided) — high multiplicity is why cutting a single holder frees little.'
|
|
755
|
+
);
|
|
756
|
+
// In holder tables keep the class NAME (don't collapse `class:Foo` → `class`):
|
|
757
|
+
// when a holder is the unclassified `class` fallback, its constructor name IS
|
|
758
|
+
// the useful identity (which class holds this?). Model concepts still group to
|
|
759
|
+
// their first segment.
|
|
760
|
+
const holderLabel = label => (label.startsWith('class:') ? label : conceptGroup(label));
|
|
761
|
+
const domByGroup = regroupTally(dominators, holderLabel);
|
|
762
|
+
report.note('Exclusive owner (dominator concept):');
|
|
763
|
+
report.table(
|
|
764
|
+
['Owner', 'Count', 'Share'],
|
|
765
|
+
[...domByGroup.entries()]
|
|
766
|
+
.sort((a, b) => b[1] - a[1])
|
|
767
|
+
.slice(0, 12)
|
|
768
|
+
.map(([owner, count]) => [owner, count.toLocaleString(), pct(count, targetCount)])
|
|
769
|
+
);
|
|
770
|
+
// Render as `Holder.edge →` so the direction is explicit: the holder owns a
|
|
771
|
+
// field that points AT the profiled concept. Collapse a numeric edge name to
|
|
772
|
+
// `[]` (memlab reports array-element edges as numeric names, so an array
|
|
773
|
+
// backing store otherwise explodes into one row per slot: 0, 1, … 34); group
|
|
774
|
+
// the holder side to its concept as usual.
|
|
775
|
+
const refByGroup = regroupTally(referrers, key => {
|
|
776
|
+
const at = key.indexOf('@');
|
|
777
|
+
const edge = key.slice(0, at);
|
|
778
|
+
const edgeDisplay = /^\d+$/.test(edge) ? '[]' : edge;
|
|
779
|
+
return `${holderLabel(key.slice(at + 1))}.${edgeDisplay} → ${group}`;
|
|
780
|
+
});
|
|
781
|
+
const refTotal = [...refByGroup.values()].reduce((sum, count) => sum + count, 0);
|
|
782
|
+
report.note(`All referrers (\`Holder.field → ${group}\` = a field of Holder points at ${group}):`);
|
|
783
|
+
report.table(
|
|
784
|
+
['Referrer', 'Count', 'Share'],
|
|
785
|
+
[...refByGroup.entries()]
|
|
786
|
+
.sort((a, b) => b[1] - a[1])
|
|
787
|
+
.slice(0, 15)
|
|
788
|
+
.map(([key, count]) => ['`' + key + '`', count.toLocaleString(), pct(count, refTotal)])
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const markdown = report.toString();
|
|
794
|
+
process.stdout.write(markdown + '\n');
|
|
795
|
+
if (outFile) {
|
|
796
|
+
fs.writeFileSync(outFile, markdown);
|
|
797
|
+
progress(`Wrote Markdown report to ${outFile}`);
|
|
798
|
+
}
|