@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 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
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-perf-gate.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-perf-gate)
4
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Hot%20path-00C853?style=for-the-badge&logo=leaf&logoColor=white)
5
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
6
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-perf-gate?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-perf-gate)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-perf-gate?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-perf-gate)
8
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-perf-gate?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-perf-gate)
9
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
10
+ ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](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
+ }