@zakkster/lite-perf-gate 1.0.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/PerfGate.d.ts +125 -0
- package/PerfGate.js +405 -0
- package/README.md +139 -0
- package/llms.txt +51 -0
- package/package.json +63 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.0.0] - 2026-07-07
|
|
4
|
+
|
|
5
|
+
Initial release. Generalized from the `@zakkster/lite-signal` zero-GC gate
|
|
6
|
+
(`test/zgc/`) into a standalone, engine-agnostic library.
|
|
7
|
+
|
|
8
|
+
### Public API
|
|
9
|
+
|
|
10
|
+
- **`zgcSuite(config)`** -- register `node:test` cases for detector
|
|
11
|
+
validation, scenario gating, and must-fail self-tests.
|
|
12
|
+
- **`measure(scenario, options?)`** -- raw measurement at N and k*N.
|
|
13
|
+
- **`verdict(result, thresholds?)`** -- evaluate against thresholds.
|
|
14
|
+
- **`runGate(config)`** -- standalone human-readable report.
|
|
15
|
+
- **`formatResult(result)`** -- one-line summary.
|
|
16
|
+
- **`controlPositive` / `controlNegative`** -- built-in detector controls.
|
|
17
|
+
|
|
18
|
+
### Methodology
|
|
19
|
+
|
|
20
|
+
Three signals: scavenge count (transient allocation), custom counters
|
|
21
|
+
(exact engine internals via `statsOf`), retained-heap delta (leaks).
|
|
22
|
+
Scaling verdict: measure at N and k*N. Detector self-validation: the gate
|
|
23
|
+
refuses to judge if the controls misbehave.
|
|
24
|
+
|
|
25
|
+
### Tests
|
|
26
|
+
|
|
27
|
+
12 self-tests covering controls, verdict logic, counter deltas, and
|
|
28
|
+
result shape.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PerfGate.d.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-perf-gate
|
|
3
|
+
* Zero-GC and performance regression gate for node:test.
|
|
4
|
+
*
|
|
5
|
+
* Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
6
|
+
* MIT License
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export declare const VERSION: string;
|
|
10
|
+
|
|
11
|
+
/** A measurement scenario. Engine-agnostic. */
|
|
12
|
+
export interface Scenario<State = any> {
|
|
13
|
+
/** Human-readable name shown in reports and test labels. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Build the graph / state. Called once per measurement pass. */
|
|
16
|
+
setup(): State;
|
|
17
|
+
/**
|
|
18
|
+
* The hot loop. Must run `n` iterations against `state`. This is the
|
|
19
|
+
* ONLY code inside the measurement window.
|
|
20
|
+
*/
|
|
21
|
+
hot(state: State, n: number): void;
|
|
22
|
+
/**
|
|
23
|
+
* Return an object of numeric counters (e.g. `{ poolGrowths: 0 }`).
|
|
24
|
+
* Called before and after the hot loop; deltas are computed automatically.
|
|
25
|
+
*/
|
|
26
|
+
statsOf?(state: State): Record<string, number>;
|
|
27
|
+
/** Clean up after measurement. */
|
|
28
|
+
teardown?(state: State): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MeasureResult {
|
|
32
|
+
name: string;
|
|
33
|
+
N: number;
|
|
34
|
+
k: number;
|
|
35
|
+
/** Minor GC (scavenge) count at N iterations. */
|
|
36
|
+
minorLo: number;
|
|
37
|
+
/** Minor GC (scavenge) count at k*N iterations. */
|
|
38
|
+
minorHi: number;
|
|
39
|
+
majorLo: number;
|
|
40
|
+
majorHi: number;
|
|
41
|
+
retainedKB_lo: number;
|
|
42
|
+
retainedKB_hi: number;
|
|
43
|
+
/** Custom counter deltas at N, or null if no statsOf. */
|
|
44
|
+
counters_lo: Record<string, number> | null;
|
|
45
|
+
/** Custom counter deltas at k*N, or null if no statsOf. */
|
|
46
|
+
counters_hi: Record<string, number> | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Thresholds {
|
|
50
|
+
/** Max allowed scavenges at k*N. Default 2. */
|
|
51
|
+
maxScavenges?: number;
|
|
52
|
+
/** Max retained heap growth in KB. Default 64. */
|
|
53
|
+
maxRetainedKB?: number;
|
|
54
|
+
/** Per-counter maximum allowed delta. */
|
|
55
|
+
counters?: Record<string, number>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Verdict {
|
|
59
|
+
pass: boolean;
|
|
60
|
+
reasons: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface GateConfig {
|
|
64
|
+
/** Zero-GC scenarios to gate. */
|
|
65
|
+
scenarios: Scenario[];
|
|
66
|
+
/** Iteration count (low). Default 200000. */
|
|
67
|
+
N?: number;
|
|
68
|
+
/** Scale factor (high = k*N). Default 8. */
|
|
69
|
+
k?: number;
|
|
70
|
+
/** Max allowed scavenges at k*N. Default 2. */
|
|
71
|
+
maxScavenges?: number;
|
|
72
|
+
/** Max retained heap growth in KB. Default 64. */
|
|
73
|
+
maxRetainedKB?: number;
|
|
74
|
+
/** Counter thresholds for statsOf deltas. */
|
|
75
|
+
counters?: Record<string, number>;
|
|
76
|
+
/** Override positive control. */
|
|
77
|
+
positiveControl?: Scenario;
|
|
78
|
+
/** Override negative control. */
|
|
79
|
+
negativeControl?: Scenario;
|
|
80
|
+
/** Scenarios that MUST trip the gate (injected allocation self-tests). */
|
|
81
|
+
mustFail?: Scenario[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface GateResult {
|
|
85
|
+
passed: boolean;
|
|
86
|
+
results: MeasureResult[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Measure a scenario at N and k*N iterations. Returns raw measurements.
|
|
91
|
+
*/
|
|
92
|
+
export function measure(
|
|
93
|
+
scenario: Scenario,
|
|
94
|
+
options?: { N?: number; k?: number }
|
|
95
|
+
): Promise<MeasureResult>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Evaluate a measurement result against thresholds.
|
|
99
|
+
*/
|
|
100
|
+
export function verdict(r: MeasureResult, thresholds?: Thresholds): Verdict;
|
|
101
|
+
|
|
102
|
+
/** One-line summary of a measurement result. */
|
|
103
|
+
export function formatResult(r: MeasureResult): string;
|
|
104
|
+
|
|
105
|
+
/** Built-in positive control (allocates per iteration). */
|
|
106
|
+
export const controlPositive: Scenario;
|
|
107
|
+
|
|
108
|
+
/** Built-in negative control (pure arithmetic, zero allocation). */
|
|
109
|
+
export const controlNegative: Scenario;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Register node:test cases: detector validation, scenario gating, and
|
|
113
|
+
* must-fail self-tests. The primary public API.
|
|
114
|
+
*
|
|
115
|
+
* Run with: `node --expose-gc --max-semi-space-size=4 --test your.test.mjs`
|
|
116
|
+
*/
|
|
117
|
+
export function zgcSuite(config: GateConfig): void;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Standalone human-readable gate report. Returns pass/fail and raw results.
|
|
121
|
+
*/
|
|
122
|
+
export function runGate(config: GateConfig): Promise<GateResult>;
|
|
123
|
+
|
|
124
|
+
/** @internal */
|
|
125
|
+
export function _controlKeepAlive(): number;
|
package/PerfGate.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-perf-gate
|
|
3
|
+
* Zero-GC and performance regression gate for node:test.
|
|
4
|
+
*
|
|
5
|
+
* Three measurement signals, each for what it can actually see:
|
|
6
|
+
* 1. Scavenge count (perf_hooks 'gc', minor) -- the reliable detector
|
|
7
|
+
* of TRANSIENT allocation. Retained-heap misses it (freed before
|
|
8
|
+
* snapshot); the V8 sampling profiler misses it (reports live-at-stop).
|
|
9
|
+
* 2. Custom counters (user-supplied statsOf) -- exact engine internals.
|
|
10
|
+
* 3. Retained-heap delta (memoryUsage + gc) -- leak detector.
|
|
11
|
+
*
|
|
12
|
+
* Pass/fail uses scaling: measure at N and k*N. Zero-alloc => ~0 scavenges
|
|
13
|
+
* at both; allocation => scavenges scale with total bytes. Controls validate
|
|
14
|
+
* the detector on every run.
|
|
15
|
+
*
|
|
16
|
+
* Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
17
|
+
* MIT License
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const VERSION = '1.0.0';
|
|
21
|
+
|
|
22
|
+
import {PerformanceObserver, constants} from 'node:perf_hooks';
|
|
23
|
+
import {setTimeout as sleep} from 'node:timers/promises';
|
|
24
|
+
import {test} from 'node:test';
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
|
|
27
|
+
const MINOR = constants.NODE_PERFORMANCE_GC_MINOR;
|
|
28
|
+
const MAJOR = constants.NODE_PERFORMANCE_GC_MAJOR;
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// GC counter
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
function makeGcCounter() {
|
|
35
|
+
const c = {minor: 0, major: 0};
|
|
36
|
+
const obs = new PerformanceObserver(function (list) {
|
|
37
|
+
for (const e of list.getEntries()) {
|
|
38
|
+
const k = e.detail ? e.detail.kind : e.kind;
|
|
39
|
+
if (k === MINOR) c.minor++;
|
|
40
|
+
else if (k === MAJOR) c.major++;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
obs.observe({entryTypes: ['gc']});
|
|
44
|
+
return {
|
|
45
|
+
c: c, close: function () {
|
|
46
|
+
obs.disconnect();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function gc2() {
|
|
52
|
+
if (typeof globalThis.gc === 'function') {
|
|
53
|
+
globalThis.gc();
|
|
54
|
+
globalThis.gc();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Core measurement
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
async function meterOnce(scenario, iters) {
|
|
63
|
+
const state = scenario.setup();
|
|
64
|
+
scenario.hot(state, Math.min(iters, 20000));
|
|
65
|
+
gc2();
|
|
66
|
+
|
|
67
|
+
const statsBefore = scenario.statsOf ? scenario.statsOf(state) : null;
|
|
68
|
+
const heapBefore = process.memoryUsage().heapUsed;
|
|
69
|
+
|
|
70
|
+
const gcc = makeGcCounter();
|
|
71
|
+
scenario.hot(state, iters);
|
|
72
|
+
await sleep(40);
|
|
73
|
+
const minor = gcc.c.minor;
|
|
74
|
+
const major = gcc.c.major;
|
|
75
|
+
gcc.close();
|
|
76
|
+
|
|
77
|
+
gc2();
|
|
78
|
+
const heapAfter = process.memoryUsage().heapUsed;
|
|
79
|
+
const statsAfter = scenario.statsOf ? scenario.statsOf(state) : null;
|
|
80
|
+
if (scenario.teardown) scenario.teardown(state);
|
|
81
|
+
|
|
82
|
+
let counters = null;
|
|
83
|
+
if (statsBefore !== null && statsAfter !== null) {
|
|
84
|
+
counters = {};
|
|
85
|
+
for (const key in statsAfter) {
|
|
86
|
+
if (typeof statsAfter[key] === 'number' && typeof statsBefore[key] === 'number') {
|
|
87
|
+
counters[key] = statsAfter[key] - statsBefore[key];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {minor: minor, major: major, retainedKB: (heapAfter - heapBefore) / 1024, counters: counters};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// measure -- public, returns raw numbers
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {object} Scenario
|
|
101
|
+
* @property {string} name
|
|
102
|
+
* @property {() => any} setup Build the graph / state. Called once.
|
|
103
|
+
* @property {(state: any, n: number) => void} hot
|
|
104
|
+
* The hot loop. Must run `n` iterations against `state`. This is the ONLY
|
|
105
|
+
* code inside the measurement window.
|
|
106
|
+
* @property {(state: any) => object} [statsOf]
|
|
107
|
+
* Return an object of numeric counters. Called before and after; deltas
|
|
108
|
+
* are computed automatically.
|
|
109
|
+
* @property {(state: any) => void} [teardown]
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Measure a scenario at two iteration counts (N and k*N) to detect
|
|
114
|
+
* allocation via scavenge scaling.
|
|
115
|
+
*
|
|
116
|
+
* @param {Scenario} scenario
|
|
117
|
+
* @param {{ N?: number, k?: number }} [options]
|
|
118
|
+
* @returns {Promise<MeasureResult>}
|
|
119
|
+
*/
|
|
120
|
+
export async function measure(scenario, options) {
|
|
121
|
+
const N = (options && options.N) || 200000;
|
|
122
|
+
const k = (options && options.k) || 8;
|
|
123
|
+
const lo = await meterOnce(scenario, N);
|
|
124
|
+
const hi = await meterOnce(scenario, k * N);
|
|
125
|
+
return {
|
|
126
|
+
name: scenario.name, N: N, k: k,
|
|
127
|
+
minorLo: lo.minor, minorHi: hi.minor,
|
|
128
|
+
majorLo: lo.major, majorHi: hi.major,
|
|
129
|
+
retainedKB_lo: lo.retainedKB, retainedKB_hi: hi.retainedKB,
|
|
130
|
+
counters_lo: lo.counters, counters_hi: hi.counters
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Built-in controls
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
const __posKeep = [];
|
|
139
|
+
|
|
140
|
+
/** @internal -- reference to keep the positive-control sink alive. */
|
|
141
|
+
export function _controlKeepAlive() {
|
|
142
|
+
return __posKeep.length;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Positive control: allocates a heap object per iteration. */
|
|
146
|
+
export const controlPositive = {
|
|
147
|
+
name: 'CONTROL+ (allocates {x,y,z,w} per iter)',
|
|
148
|
+
setup: function () {
|
|
149
|
+
return {};
|
|
150
|
+
},
|
|
151
|
+
hot: function (_s, n) {
|
|
152
|
+
for (let i = 0; i < n; i++) __posKeep.push({x: i, y: i + 1, z: i + 2, w: i + 3});
|
|
153
|
+
if (__posKeep.length > 3000000) __posKeep.length = 0;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Negative control: pure arithmetic, zero allocation. */
|
|
158
|
+
export const controlNegative = {
|
|
159
|
+
name: 'CONTROL- (pure arithmetic)',
|
|
160
|
+
setup: function () {
|
|
161
|
+
return {acc: 0};
|
|
162
|
+
},
|
|
163
|
+
hot: function (s, n) {
|
|
164
|
+
let a = s.acc;
|
|
165
|
+
for (let i = 0; i < n; i++) a += (i * 3) ^ i;
|
|
166
|
+
s.acc = a;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Verdict
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Evaluate a measurement against thresholds.
|
|
176
|
+
*
|
|
177
|
+
* @param {MeasureResult} r
|
|
178
|
+
* @param {object} [thresholds]
|
|
179
|
+
* @param {number} [thresholds.maxScavenges=2]
|
|
180
|
+
* @param {number} [thresholds.maxRetainedKB=64]
|
|
181
|
+
* @param {Record<string, number>} [thresholds.counters]
|
|
182
|
+
* Per-counter maximum allowed delta. E.g. `{ poolGrowths: 0 }`.
|
|
183
|
+
* @returns {{ pass: boolean, reasons: string[] }}
|
|
184
|
+
*/
|
|
185
|
+
export function verdict(r, thresholds) {
|
|
186
|
+
const maxScav = (thresholds && thresholds.maxScavenges !== undefined) ? thresholds.maxScavenges : 2;
|
|
187
|
+
const maxRetKB = (thresholds && thresholds.maxRetainedKB !== undefined) ? thresholds.maxRetainedKB : 64;
|
|
188
|
+
const ct = (thresholds && thresholds.counters) || null;
|
|
189
|
+
const reasons = [];
|
|
190
|
+
|
|
191
|
+
if (r.minorHi > maxScav) {
|
|
192
|
+
reasons.push('scavenges: ' + r.minorHi + ' > ' + maxScav + ' (transient allocation)');
|
|
193
|
+
}
|
|
194
|
+
if (r.retainedKB_hi > maxRetKB) {
|
|
195
|
+
reasons.push('retained: ' + r.retainedKB_hi.toFixed(0) + 'KB > ' + maxRetKB + 'KB');
|
|
196
|
+
}
|
|
197
|
+
if (ct !== null && r.counters_hi !== null) {
|
|
198
|
+
for (const key in ct) {
|
|
199
|
+
const actual = r.counters_hi[key];
|
|
200
|
+
if (actual !== undefined && actual > ct[key]) {
|
|
201
|
+
reasons.push(key + ': ' + actual + ' > ' + ct[key]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {pass: reasons.length === 0, reasons: reasons};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Format
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One-line summary of a measurement result.
|
|
214
|
+
* @param {MeasureResult} r
|
|
215
|
+
* @returns {string}
|
|
216
|
+
*/
|
|
217
|
+
export function formatResult(r) {
|
|
218
|
+
let s = r.name +
|
|
219
|
+
'\n scavenges N:' + String(r.minorLo).padStart(3) +
|
|
220
|
+
' ' + r.k + 'N:' + String(r.minorHi).padStart(3) +
|
|
221
|
+
' retained ' + r.retainedKB_hi.toFixed(0) + 'KB';
|
|
222
|
+
if (r.counters_hi !== null) {
|
|
223
|
+
for (const key in r.counters_hi) {
|
|
224
|
+
s += ' ' + key + ' \u0394' + String(r.counters_hi[key]).padStart(6);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return s;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// zgcSuite -- the node:test integration (primary public API)
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Register node:test cases that validate detector controls, measure each
|
|
236
|
+
* scenario, and assert the zero-GC verdict.
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* import { zgcSuite } from '@zakkster/lite-perf-gate';
|
|
240
|
+
*
|
|
241
|
+
* zgcSuite({
|
|
242
|
+
* scenarios: [
|
|
243
|
+
* { name: 'deep chain', setup: buildDeep, hot: runDeep,
|
|
244
|
+
* statsOf: s => s.registry.stats() },
|
|
245
|
+
* ],
|
|
246
|
+
* counters: { poolGrowths: 0, totalAllocations: 0 },
|
|
247
|
+
* mustFail: [injectedAllocScenario]
|
|
248
|
+
* });
|
|
249
|
+
*
|
|
250
|
+
* Run with: node --expose-gc --max-semi-space-size=4 --test your.test.mjs
|
|
251
|
+
*
|
|
252
|
+
* @param {object} config
|
|
253
|
+
* @param {Scenario[]} config.scenarios Zero-GC scenarios to gate.
|
|
254
|
+
* @param {number} [config.N=200000] Iteration count (low).
|
|
255
|
+
* @param {number} [config.k=8] Scale factor (high = k*N).
|
|
256
|
+
* @param {number} [config.maxScavenges=2] Max allowed scavenges at k*N.
|
|
257
|
+
* @param {number} [config.maxRetainedKB=64] Max retained heap growth.
|
|
258
|
+
* @param {Record<string, number>} [config.counters]
|
|
259
|
+
* Counter thresholds for statsOf deltas.
|
|
260
|
+
* @param {Scenario} [config.positiveControl] Override positive control.
|
|
261
|
+
* @param {Scenario} [config.negativeControl] Override negative control.
|
|
262
|
+
* @param {Scenario[]} [config.mustFail]
|
|
263
|
+
* Scenarios that MUST trip the gate (injected allocation self-tests).
|
|
264
|
+
*/
|
|
265
|
+
export function zgcSuite(config) {
|
|
266
|
+
const scenarios = config.scenarios;
|
|
267
|
+
const opts = {N: config.N || 200000, k: config.k || 8};
|
|
268
|
+
const thresholds = {
|
|
269
|
+
maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
|
|
270
|
+
maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
|
|
271
|
+
counters: config.counters || null
|
|
272
|
+
};
|
|
273
|
+
const posCtrl = config.positiveControl || controlPositive;
|
|
274
|
+
const negCtrl = config.negativeControl || controlNegative;
|
|
275
|
+
const mustFail = config.mustFail || [];
|
|
276
|
+
const maxScav = thresholds.maxScavenges;
|
|
277
|
+
|
|
278
|
+
test('perf-gate: detector sees a known allocation (positive control)', async function () {
|
|
279
|
+
const r = await measure(posCtrl, opts);
|
|
280
|
+
_controlKeepAlive();
|
|
281
|
+
assert.ok(r.minorHi > maxScav + 3,
|
|
282
|
+
'positive control should force scavenges, saw ' + r.minorHi +
|
|
283
|
+
' (need >' + (maxScav + 3) + '). Run with --expose-gc --max-semi-space-size=4.');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('perf-gate: detector reads ~0 for a non-allocating loop (negative control)', async function () {
|
|
287
|
+
const r = await measure(negCtrl, opts);
|
|
288
|
+
assert.ok(r.minorHi <= maxScav,
|
|
289
|
+
'no-op control forced ' + r.minorHi + ' scavenges (need <=' + maxScav + ')');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
for (let i = 0; i < scenarios.length; i++) {
|
|
293
|
+
(function (sc) {
|
|
294
|
+
test('zero-GC: ' + sc.name, async function () {
|
|
295
|
+
const r = await measure(sc, opts);
|
|
296
|
+
const v = verdict(r, thresholds);
|
|
297
|
+
assert.ok(v.pass,
|
|
298
|
+
sc.name + ' FAILED:\n ' + v.reasons.join('\n ') +
|
|
299
|
+
'\n ' + formatResult(r));
|
|
300
|
+
});
|
|
301
|
+
})(scenarios[i]);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (let j = 0; j < mustFail.length; j++) {
|
|
305
|
+
(function (sc) {
|
|
306
|
+
test('perf-gate: MUST CATCH ' + sc.name, async function () {
|
|
307
|
+
const r = await measure(sc, opts);
|
|
308
|
+
const v = verdict(r, thresholds);
|
|
309
|
+
assert.ok(!v.pass,
|
|
310
|
+
'gate failed to flag a known allocation: ' + sc.name +
|
|
311
|
+
'\n ' + formatResult(r));
|
|
312
|
+
});
|
|
313
|
+
})(mustFail[j]);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// runGate -- standalone human-readable report
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Run a gate check and print a human-readable report. Exit codes:
|
|
323
|
+
* 0 = pass, 1 = allocation detected, 2 = detector validation failed.
|
|
324
|
+
*
|
|
325
|
+
* @param {object} config Same shape as zgcSuite.
|
|
326
|
+
* @returns {Promise<{ passed: boolean, results: MeasureResult[] }>}
|
|
327
|
+
*/
|
|
328
|
+
export async function runGate(config) {
|
|
329
|
+
const scenarios = config.scenarios;
|
|
330
|
+
const opts = {N: config.N || 200000, k: config.k || 8};
|
|
331
|
+
const thresholds = {
|
|
332
|
+
maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
|
|
333
|
+
maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
|
|
334
|
+
counters: config.counters || null
|
|
335
|
+
};
|
|
336
|
+
const posCtrl = config.positiveControl || controlPositive;
|
|
337
|
+
const negCtrl = config.negativeControl || controlNegative;
|
|
338
|
+
const mustFail = config.mustFail || [];
|
|
339
|
+
const maxScav = thresholds.maxScavenges;
|
|
340
|
+
const N = opts.N;
|
|
341
|
+
const k = opts.k;
|
|
342
|
+
|
|
343
|
+
console.log('Zero-GC gate \u2014 scavenge scaling (N=' + N + ', ' + k + 'N=' + (k * N) + ')\n');
|
|
344
|
+
|
|
345
|
+
const pos = await measure(posCtrl, opts);
|
|
346
|
+
const neg = await measure(negCtrl, opts);
|
|
347
|
+
console.log('== controls ==');
|
|
348
|
+
console.log(formatResult(pos));
|
|
349
|
+
console.log(formatResult(neg));
|
|
350
|
+
_controlKeepAlive();
|
|
351
|
+
|
|
352
|
+
if (pos.minorHi <= maxScav + 3 || neg.minorHi > maxScav) {
|
|
353
|
+
console.log('\n!! DETECTOR VALIDATION FAILED: positive ' + pos.minorHi +
|
|
354
|
+
' (need >' + (maxScav + 3) + '), negative ' + neg.minorHi +
|
|
355
|
+
' (need <=' + maxScav + ').');
|
|
356
|
+
return {passed: false, results: []};
|
|
357
|
+
}
|
|
358
|
+
console.log('\ndetector validated: positive forced ' + pos.minorHi +
|
|
359
|
+
' scavenges, negative forced ' + neg.minorHi + '.\n');
|
|
360
|
+
|
|
361
|
+
console.log('== scenarios ==');
|
|
362
|
+
const results = [];
|
|
363
|
+
for (let i = 0; i < scenarios.length; i++) {
|
|
364
|
+
const r = await measure(scenarios[i], opts);
|
|
365
|
+
results.push(r);
|
|
366
|
+
console.log(formatResult(r));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let mustFailOK = true;
|
|
370
|
+
if (mustFail.length > 0) {
|
|
371
|
+
console.log('\n== must-fail (gate self-test) ==');
|
|
372
|
+
for (let j = 0; j < mustFail.length; j++) {
|
|
373
|
+
const mf = await measure(mustFail[j], opts);
|
|
374
|
+
const mfv = verdict(mf, thresholds);
|
|
375
|
+
if (mfv.pass) mustFailOK = false;
|
|
376
|
+
console.log((mfv.pass ? 'MISSED ' : 'CAUGHT ') + formatResult(mf));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
console.log('\n' + '='.repeat(72));
|
|
381
|
+
const failures = [];
|
|
382
|
+
for (let s = 0; s < results.length; s++) {
|
|
383
|
+
const v = verdict(results[s], thresholds);
|
|
384
|
+
console.log(' ' + (v.pass ? 'PASS' : 'FAIL') + ' ' + results[s].name);
|
|
385
|
+
if (!v.pass) {
|
|
386
|
+
failures.push(results[s]);
|
|
387
|
+
for (let ri = 0; ri < v.reasons.length; ri++) {
|
|
388
|
+
console.log(' ' + v.reasons[ri]);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
console.log('='.repeat(72));
|
|
393
|
+
|
|
394
|
+
const passed = failures.length === 0 && mustFailOK;
|
|
395
|
+
if (passed) {
|
|
396
|
+
console.log('\nZERO-GC GATE: PASS \u2014 ' + results.length + '/' + results.length + ' scenarios.');
|
|
397
|
+
} else {
|
|
398
|
+
const why = failures.map(function (f) {
|
|
399
|
+
return f.name;
|
|
400
|
+
});
|
|
401
|
+
if (!mustFailOK) why.push('must-fail scenario was not caught');
|
|
402
|
+
console.log('\nZERO-GC GATE: FAIL \u2014 ' + why.join('; '));
|
|
403
|
+
}
|
|
404
|
+
return {passed: passed, results: results};
|
|
405
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @zakkster/lite-perf-gate
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@zakkster/lite-perf-gate)
|
|
4
|
+

|
|
5
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
6
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-perf-gate)
|
|
7
|
+
[](https://www.npmjs.com/package/@zakkster/lite-perf-gate)
|
|
8
|
+
[](https://www.npmjs.com/package/@zakkster/lite-perf-gate)
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+
[](https://opensource.org/licenses/MIT)
|
|
12
|
+
|
|
13
|
+
> Prove your hot path allocates nothing -- or name what did.
|
|
14
|
+
|
|
15
|
+
Zero-GC and performance regression gate for `node:test`. Scavenge-counting with scaling verdict, built-in detector self-validation, and CI pass/fail. No sampling, no heuristics, no false passes.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @zakkster/lite-perf-gate
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Why this exists
|
|
22
|
+
|
|
23
|
+
`benchmark.js` is dead. `tinybench` and `mitata` measure throughput but don't **gate** -- they can't fail a CI build when a hot path starts allocating. The V8 sampling heap profiler reports live-at-stop bytes, silently **missing transient garbage** -- the exact GC pressure a zero-alloc claim is about. Retained-heap delta (`heapUsed` before/after + `gc()`) misses it too: freed before snapshot.
|
|
24
|
+
|
|
25
|
+
Scavenge-counting is the only reliable detector of transient allocation. This library packages the methodology into a `node:test`-native harness that validates its own detector on every run.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
// test/zero-gc.test.mjs
|
|
31
|
+
import { zgcSuite } from '@zakkster/lite-perf-gate';
|
|
32
|
+
|
|
33
|
+
zgcSuite({
|
|
34
|
+
scenarios: [
|
|
35
|
+
{
|
|
36
|
+
name: 'steady-state propagation',
|
|
37
|
+
setup() {
|
|
38
|
+
const engine = buildYourGraph();
|
|
39
|
+
return engine;
|
|
40
|
+
},
|
|
41
|
+
hot(engine, n) {
|
|
42
|
+
for (let i = 0; i < n; i++) engine.update(i);
|
|
43
|
+
},
|
|
44
|
+
statsOf(engine) {
|
|
45
|
+
return engine.stats(); // { poolGrowths, totalAllocations, ... }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
counters: { poolGrowths: 0, totalAllocations: 0 }
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
node --expose-gc --max-semi-space-size=4 --test test/zero-gc.test.mjs
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## What it does
|
|
58
|
+
|
|
59
|
+
1. **Validates the detector.** A positive control (allocates per iteration) must force scavenges. A negative control (pure arithmetic) must force ~0. If either fails, the gate refuses to judge -- you get an explicit "detector broken" error, not a silent false pass.
|
|
60
|
+
|
|
61
|
+
2. **Measures at two scales.** Each scenario runs at N and k\*N iterations. A zero-alloc path shows ~0 scavenges at both; an allocating path shows scavenges scaling with bytes. The comparison is not hostage to one absolute threshold.
|
|
62
|
+
|
|
63
|
+
3. **Asserts the verdict.** Scavenges, retained-heap, and custom counter deltas are checked against thresholds. A failure names the signal that tripped.
|
|
64
|
+
|
|
65
|
+
4. **Self-tests the gate.** `mustFail` scenarios inject a known allocation and assert the gate catches it. If your gate can't catch a leak, it tells you.
|
|
66
|
+
|
|
67
|
+
## Three signals, each for what it can see
|
|
68
|
+
|
|
69
|
+
| Signal | Detects | Misses |
|
|
70
|
+
|--------|---------|--------|
|
|
71
|
+
| Scavenge count (`perf_hooks` GC minor) | Transient allocation (the GC pressure zero-alloc claims are about) | Nothing in the young-gen domain |
|
|
72
|
+
| Custom counters (`statsOf` deltas) | Exact engine internals (pool growth, node allocation) | Anything outside the engine's own bookkeeping |
|
|
73
|
+
| Retained-heap delta (`memoryUsage` + `gc()`) | Leaks (memory that survives GC) | Transient garbage (freed before snapshot) |
|
|
74
|
+
|
|
75
|
+
## API
|
|
76
|
+
|
|
77
|
+
### `zgcSuite(config)`
|
|
78
|
+
|
|
79
|
+
Register `node:test` cases for detector validation, scenario gating, and must-fail self-tests.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
interface GateConfig {
|
|
83
|
+
scenarios: Scenario[]; // your hot-path scenarios
|
|
84
|
+
N?: number; // iteration count, default 200000
|
|
85
|
+
k?: number; // scale factor, default 8
|
|
86
|
+
maxScavenges?: number; // threshold at k*N, default 2
|
|
87
|
+
maxRetainedKB?: number; // heap growth threshold, default 64
|
|
88
|
+
counters?: Record<string, number>; // per-counter max delta
|
|
89
|
+
positiveControl?: Scenario; // override built-in
|
|
90
|
+
negativeControl?: Scenario; // override built-in
|
|
91
|
+
mustFail?: Scenario[]; // must trip the gate
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### `measure(scenario, options?)`
|
|
96
|
+
|
|
97
|
+
Raw measurement at N and k\*N. Returns `MeasureResult` with scavenge counts, retained heap, and counter deltas.
|
|
98
|
+
|
|
99
|
+
### `verdict(result, thresholds?)`
|
|
100
|
+
|
|
101
|
+
Evaluate a `MeasureResult` against thresholds. Returns `{ pass: boolean, reasons: string[] }`.
|
|
102
|
+
|
|
103
|
+
### `runGate(config)`
|
|
104
|
+
|
|
105
|
+
Standalone human-readable report. Same config as `zgcSuite`. Returns `{ passed, results }`.
|
|
106
|
+
|
|
107
|
+
### `Scenario`
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
interface Scenario<State = any> {
|
|
111
|
+
name: string;
|
|
112
|
+
setup(): State;
|
|
113
|
+
hot(state: State, n: number): void;
|
|
114
|
+
statsOf?(state: State): Record<string, number>;
|
|
115
|
+
teardown?(state: State): void;
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The `hot` function is the ONLY code inside the measurement window. `setup` and `teardown` run outside.
|
|
120
|
+
|
|
121
|
+
## Measurement traps (why it's built this way)
|
|
122
|
+
|
|
123
|
+
Two approaches were tried and rejected -- both produce **false passes**:
|
|
124
|
+
|
|
125
|
+
- **Retained-heap delta alone** only sees retention. A hot path that allocates and frees every iteration shows ~0 delta -- yet that transient churn is exactly the GC pressure the claim is about.
|
|
126
|
+
- **V8 sampling heap profiler** (`HeapProfiler.startSampling`) reports live-at-stop sampled bytes, so it likewise misses transient garbage.
|
|
127
|
+
|
|
128
|
+
Two V8 gotchas the controls handle:
|
|
129
|
+
|
|
130
|
+
- **Escape analysis / scalar replacement** erases function-local throwaway allocations entirely. The positive control allocates into a module-level sink that genuinely escapes.
|
|
131
|
+
- **GC PerformanceObserver entries are async.** The window is followed by a timer tick before the count is read, and the count is snapshotted before the harness's own `gc()` calls.
|
|
132
|
+
|
|
133
|
+
## `--max-semi-space-size=4`
|
|
134
|
+
|
|
135
|
+
The default V8 semi-space is 16MB. At 200k iterations of 64-byte objects, you produce ~12MB -- possibly fitting without a single scavenge. `--max-semi-space-size=4` shrinks the young generation to 4MB, forcing scavenges on smaller cumulative allocation and sharpening sensitivity. The four-field positive control (`{x,y,z,w}`) provides margin even without this flag, but the flag is recommended for production gates.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT (c) Zahary Shinikchiev
|
package/llms.txt
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @zakkster/lite-perf-gate
|
|
2
|
+
|
|
3
|
+
Zero-GC and performance regression gate for node:test.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Proves a hot path allocates nothing, or names what did. Three signals:
|
|
8
|
+
1. Scavenge count (perf_hooks GC minor) -- transient allocation.
|
|
9
|
+
2. Custom counters (user-supplied statsOf) -- exact engine internals.
|
|
10
|
+
3. Retained-heap delta (memoryUsage + gc) -- leak detector.
|
|
11
|
+
|
|
12
|
+
Scaling verdict: measure at N and k*N. Zero-alloc => ~0 scavenges at both.
|
|
13
|
+
Detector self-validation: positive control (must scavenge) + negative
|
|
14
|
+
control (must not scavenge) run on every gate invocation. If either fails,
|
|
15
|
+
the gate refuses to judge.
|
|
16
|
+
|
|
17
|
+
## Primary API
|
|
18
|
+
|
|
19
|
+
zgcSuite(config) -- register node:test cases.
|
|
20
|
+
config.scenarios: Scenario[] -- the hot paths to gate.
|
|
21
|
+
config.counters: Record<string, number> -- per-counter max delta.
|
|
22
|
+
config.mustFail: Scenario[] -- must trip the gate (self-test).
|
|
23
|
+
config.N: number (default 200000), config.k: number (default 8).
|
|
24
|
+
|
|
25
|
+
measure(scenario, {N, k}) -- raw MeasureResult.
|
|
26
|
+
verdict(result, thresholds) -- { pass: boolean, reasons: string[] }.
|
|
27
|
+
runGate(config) -- standalone report, same config as zgcSuite.
|
|
28
|
+
|
|
29
|
+
## Scenario shape
|
|
30
|
+
|
|
31
|
+
{ name, setup(), hot(state, n), statsOf?(state), teardown?(state) }
|
|
32
|
+
|
|
33
|
+
setup() builds the graph. hot(state, n) is the ONLY code in the window.
|
|
34
|
+
statsOf returns numeric counters; deltas are computed automatically.
|
|
35
|
+
|
|
36
|
+
## Run with
|
|
37
|
+
|
|
38
|
+
node --expose-gc --max-semi-space-size=4 --test your.test.mjs
|
|
39
|
+
|
|
40
|
+
--expose-gc is required. --max-semi-space-size=4 sharpens sensitivity.
|
|
41
|
+
|
|
42
|
+
## Why scavenge counting
|
|
43
|
+
|
|
44
|
+
Retained-heap delta misses transient garbage (freed before snapshot).
|
|
45
|
+
V8 sampling profiler misses it (reports live-at-stop). Scavenge counting
|
|
46
|
+
is the only reliable detector of transient allocation in V8.
|
|
47
|
+
|
|
48
|
+
## Zero dependencies
|
|
49
|
+
|
|
50
|
+
Single-file ESM. Imports only node:perf_hooks, node:timers/promises,
|
|
51
|
+
node:test, node:assert/strict.
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zakkster/lite-perf-gate",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
5
|
+
"description": "Zero-GC and performance regression gate for node:test. Scavenge-counting, scaling verdict, detector self-validation. Proves your hot path allocates nothing -- or names what did.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./PerfGate.js",
|
|
9
|
+
"module": "./PerfGate.js",
|
|
10
|
+
"types": "./PerfGate.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./PerfGate.d.ts",
|
|
14
|
+
"node": "./PerfGate.js",
|
|
15
|
+
"import": "./PerfGate.js",
|
|
16
|
+
"default": "./PerfGate.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"PerfGate.js",
|
|
21
|
+
"PerfGate.d.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"llms.txt",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --expose-gc --max-semi-space-size=4 --test test/self.test.mjs"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"benchmark",
|
|
32
|
+
"regression",
|
|
33
|
+
"gate",
|
|
34
|
+
"zero-gc",
|
|
35
|
+
"gc",
|
|
36
|
+
"scavenge",
|
|
37
|
+
"allocation",
|
|
38
|
+
"performance",
|
|
39
|
+
"node-test",
|
|
40
|
+
"ci",
|
|
41
|
+
"perf-hooks"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"homepage": "https://github.com/PeshoVurtoleta/lite-perf-gate#readme",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/PeshoVurtoleta/lite-perf-gate.git"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/PeshoVurtoleta/lite-perf-gate/issues",
|
|
54
|
+
"email": "shinikchiev@yahoo.com"
|
|
55
|
+
},
|
|
56
|
+
"funding": {
|
|
57
|
+
"type": "github",
|
|
58
|
+
"url": "https://github.com/sponsors/PeshoVurtoleta"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=18"
|
|
62
|
+
}
|
|
63
|
+
}
|