@mandujs/core 0.41.2 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,553 +1,553 @@
1
- /**
2
- * Phase 18.ψ — user-facing performance marks API
3
- *
4
- * Layered on top of the Phase 0 `@mandujs/core/perf` gate (`MANDU_PERF=1`),
5
- * this module exposes a developer-friendly surface for measuring custom
6
- * operations in application code. The existing module-level
7
- * {@link import("./index").mark} + {@link import("./index").measure} remain
8
- * unchanged for framework-internal callers; this file adds new primitives
9
- * that close the ergonomic gap identified in user feedback:
10
- *
11
- * ```ts
12
- * import { time, timeAsync, createPerf } from "@mandujs/core/perf";
13
- *
14
- * // 1. Basic scoped timing — returns a close-over end() function.
15
- * const end = time("db-query");
16
- * const rows = await db.query("SELECT ...");
17
- * end(); // emits measurement
18
- *
19
- * // 2. Async scope — auto-closed, resolved value forwarded, errors rethrown.
20
- * const data = await timeAsync("fetch-user", async () => {
21
- * return await fetch("/api/user/123").then((r) => r.json());
22
- * });
23
- *
24
- * // 3. Scoped instance (dependency-injection / opt-in per-module):
25
- * const perf = createPerf({ enabled: process.env.MANDU_PERF === "1" });
26
- * perf.time("foo")(); // no-op when disabled, zero overhead
27
- * ```
28
- *
29
- * Design contracts:
30
- * - **Zero overhead when gated off.** When {@link isPerfEnabled} is
31
- * `false`, every function is a single branch that returns a
32
- * stable no-op (see `NOOP_END`). No `Map` allocation, no histogram
33
- * entry, no span creation, no console output.
34
- * - **Gate is shared.** Framework-internal marks (HMR, SSR, bundler)
35
- * and user marks honor the same `MANDU_PERF=1` switch. Users do not
36
- * need a separate env var.
37
- * - **OTel integration is automatic.** When Phase 18.θ's request tracer
38
- * is enabled, every user mark becomes a child span under the active
39
- * request span. Attribute `mandu.perf.category = "user" | "framework"
40
- * | "bundler" | "ssr"` differentiates mark origin in the trace view.
41
- * Opt-out via `{ trace: false }`.
42
- * - **Bounded histogram buffer.** The last 1000 recorded measurements
43
- * are retained in a ring buffer for `/_mandu/heap` inspection. Older
44
- * entries are dropped in insertion order. Same cap as Phase 17's
45
- * event-bus recent buffer.
46
- * - **No new runtime deps.** Timing uses `Bun.nanoseconds()` when
47
- * available, `performance.now()` elsewhere.
48
- *
49
- * Rationale vs `mark()` + `measure()` (framework-internal):
50
- * - `mark(name)` records a start timestamp, then a later `measure(label,
51
- * name)` call computes the delta. That signature is great for
52
- * framework code where start/end happen in separate functions, but
53
- * forces users to invent unique `name` strings and wire them through
54
- * closures. The `time() => end()` shape is more natural for
55
- * application code where both ends live in the same lexical scope.
56
- * - Both APIs coexist; `time()` internally uses `Bun.nanoseconds()`
57
- * directly and does NOT share the framework's `marks: Map<string,
58
- * number>`. This avoids collisions between user-chosen names and
59
- * framework markers (e.g., if a user named a span `"hmr:rebuild-total"`).
60
- *
61
- * @module perf/user-marks
62
- */
63
-
64
- import { isPerfEnabled } from "./index";
65
- import { getTracer, getActiveSpan, runWithSpan, type Span } from "../observability/tracing";
66
-
67
- // ─── Types ──────────────────────────────────────────────────────────────
68
-
69
- /**
70
- * Origin of a mark. Framework code sets this to the appropriate value
71
- * when bridging `mark()`/`measure()` calls into the histogram. User
72
- * `time()` / `timeAsync()` always records `"user"`.
73
- */
74
- export type PerfCategory = "user" | "framework" | "bundler" | "ssr";
75
-
76
- /** Options accepted by {@link time} / {@link timeAsync} and {@link Perf.time}. */
77
- export interface PerfMarkOptions {
78
- /**
79
- * Span category for histogram attribution + OTel attribute. Default:
80
- * `"user"`. Framework code sets this explicitly.
81
- */
82
- category?: PerfCategory;
83
- /**
84
- * When `true` (default) and a tracer is active, the mark creates a
85
- * child span on `end()` with attributes `mandu.perf.category` and
86
- * duration. When `false`, histogram still records but no span emits.
87
- */
88
- trace?: boolean;
89
- /**
90
- * Extra attributes attached to the OTel child span (when
91
- * `trace !== false` and a tracer is active). Ignored otherwise.
92
- */
93
- attributes?: Record<string, string | number | boolean>;
94
- }
95
-
96
- /** Zero-arg function returned by {@link time}. Idempotent — safe to call twice. */
97
- export type PerfEndFn = () => number;
98
-
99
- /** One entry in the bounded histogram buffer. */
100
- export interface PerfMarkEntry {
101
- /** Mark label. */
102
- name: string;
103
- /** Duration in milliseconds. */
104
- durationMs: number;
105
- /** Category as set by caller. */
106
- category: PerfCategory;
107
- /** Unix epoch millis when the mark was closed. */
108
- timestamp: number;
109
- }
110
-
111
- /** Statistical summary per unique mark name. */
112
- export interface PerfHistogramEntry {
113
- name: string;
114
- count: number;
115
- mean: number;
116
- /** 50th percentile. */
117
- p50: number;
118
- /** 95th percentile. */
119
- p95: number;
120
- /** 99th percentile. */
121
- p99: number;
122
- /** Minimum observed duration (ms). */
123
- min: number;
124
- /** Maximum observed duration (ms). */
125
- max: number;
126
- }
127
-
128
- /** Shape of the `perf` block appended to `/_mandu/heap` JSON. */
129
- export interface PerfDashboardSnapshot {
130
- enabled: boolean;
131
- /** Total closed marks since process start (NOT the buffer size). */
132
- totalCount: number;
133
- /** Current number of entries retained in the ring buffer. */
134
- bufferedCount: number;
135
- /** Max ring-buffer capacity. */
136
- bufferLimit: number;
137
- /** Per-name histogram, sorted by descending `count`. */
138
- histogram: PerfHistogramEntry[];
139
- /** The most-recent N marks (up to 50), newest last. */
140
- recent: PerfMarkEntry[];
141
- }
142
-
143
- // ─── Timing primitive ───────────────────────────────────────────────────
144
-
145
- /**
146
- * High-resolution timestamp in milliseconds as a float. Prefers
147
- * `Bun.nanoseconds()` (monotonic, integer-nanos precision) and falls
148
- * back to `performance.now()` for non-Bun runtimes (tests that spawn
149
- * Node, edge worker shims).
150
- */
151
- function nowMs(): number {
152
- const bun = (globalThis as { Bun?: { nanoseconds?: () => number } }).Bun;
153
- if (bun && typeof bun.nanoseconds === "function") {
154
- return bun.nanoseconds() / 1_000_000;
155
- }
156
- if (typeof performance !== "undefined" && typeof performance.now === "function") {
157
- return performance.now();
158
- }
159
- return Date.now();
160
- }
161
-
162
- // ─── Ring buffer + running stats ─────────────────────────────────────────
163
-
164
- /**
165
- * Max entries retained in the recent-marks ring buffer. Matches the
166
- * Phase 17 event-bus recent buffer so operators get a consistent
167
- * "last N" window across `/_mandu/heap` and `/_mandu/events/recent`.
168
- */
169
- export const PERF_BUFFER_LIMIT = 1000;
170
-
171
- /**
172
- * Per-name running state. We store the raw observations for percentile
173
- * computation (bounded per-name at `PER_NAME_OBS_LIMIT` to cap memory
174
- * across a long dev session). For O(1) scrape, we also maintain count /
175
- * min / max / sum — percentiles are the only O(n log n) piece.
176
- */
177
- const PER_NAME_OBS_LIMIT = 500;
178
-
179
- interface PerNameStats {
180
- count: number;
181
- sum: number;
182
- min: number;
183
- max: number;
184
- /** Bounded window of observations (FIFO) for percentile calc. */
185
- observations: number[];
186
- /** Next write index into the ring. */
187
- obsHead: number;
188
- obsFull: boolean;
189
- }
190
-
191
- /** Ring buffer of most-recent `PerfMarkEntry` values (capacity `PERF_BUFFER_LIMIT`). */
192
- const ringBuffer: (PerfMarkEntry | undefined)[] = new Array(PERF_BUFFER_LIMIT);
193
- let ringHead = 0;
194
- let ringFull = false;
195
-
196
- /** Monotonic counter of closed marks since process start. */
197
- let totalClosedCount = 0;
198
-
199
- /** Per-name stats map. Only populated when perf is enabled. */
200
- const perNameStats = new Map<string, PerNameStats>();
201
-
202
- function getOrCreateStats(name: string): PerNameStats {
203
- let s = perNameStats.get(name);
204
- if (!s) {
205
- s = {
206
- count: 0,
207
- sum: 0,
208
- min: Number.POSITIVE_INFINITY,
209
- max: Number.NEGATIVE_INFINITY,
210
- observations: new Array(PER_NAME_OBS_LIMIT),
211
- obsHead: 0,
212
- obsFull: false,
213
- };
214
- perNameStats.set(name, s);
215
- }
216
- return s;
217
- }
218
-
219
- /** Append a closed mark to the ring buffer + update per-name stats. */
220
- function recordEntry(entry: PerfMarkEntry): void {
221
- // Ring buffer append.
222
- ringBuffer[ringHead] = entry;
223
- ringHead = (ringHead + 1) % PERF_BUFFER_LIMIT;
224
- if (ringHead === 0) ringFull = true;
225
- totalClosedCount++;
226
-
227
- // Per-name stats update.
228
- const s = getOrCreateStats(entry.name);
229
- s.count++;
230
- s.sum += entry.durationMs;
231
- if (entry.durationMs < s.min) s.min = entry.durationMs;
232
- if (entry.durationMs > s.max) s.max = entry.durationMs;
233
- s.observations[s.obsHead] = entry.durationMs;
234
- s.obsHead = (s.obsHead + 1) % PER_NAME_OBS_LIMIT;
235
- if (s.obsHead === 0) s.obsFull = true;
236
- }
237
-
238
- /** Compute the p-th percentile (0..100) of a sorted array. */
239
- function percentile(sorted: number[], p: number): number {
240
- if (sorted.length === 0) return 0;
241
- // Nearest-rank method — simple, deterministic, no interpolation
242
- // artifacts on small samples.
243
- const rank = Math.ceil((p / 100) * sorted.length);
244
- const idx = Math.max(0, Math.min(sorted.length - 1, rank - 1));
245
- return sorted[idx] ?? 0;
246
- }
247
-
248
- function buildHistogramEntry(name: string, s: PerNameStats): PerfHistogramEntry {
249
- const obs = s.obsFull
250
- ? s.observations.slice(0, PER_NAME_OBS_LIMIT)
251
- : s.observations.slice(0, s.obsHead);
252
- const sorted = [...obs].sort((a, b) => a - b);
253
- return {
254
- name,
255
- count: s.count,
256
- mean: s.count > 0 ? s.sum / s.count : 0,
257
- p50: percentile(sorted, 50),
258
- p95: percentile(sorted, 95),
259
- p99: percentile(sorted, 99),
260
- min: s.min === Number.POSITIVE_INFINITY ? 0 : s.min,
261
- max: s.max === Number.NEGATIVE_INFINITY ? 0 : s.max,
262
- };
263
- }
264
-
265
- // ─── No-op sentinel ─────────────────────────────────────────────────────
266
-
267
- const NOOP_END: PerfEndFn = () => 0;
268
-
269
- // ─── Public functions ───────────────────────────────────────────────────
270
-
271
- /**
272
- * Open a performance measurement. Returns an `end()` function that,
273
- * when called, closes the measurement and records it in the histogram
274
- * + OTel span (if tracing is active).
275
- *
276
- * When {@link isPerfEnabled} is `false`, returns a shared no-op end
277
- * function. Zero allocations on the disabled path.
278
- *
279
- * Calling `end()` more than once is a no-op on the second call; the
280
- * duration recorded is always the delta from the first `time()` call
281
- * to the first `end()` call.
282
- */
283
- export function time(name: string, options: PerfMarkOptions = {}): PerfEndFn {
284
- if (!isPerfEnabled()) return NOOP_END;
285
- const category: PerfCategory = options.category ?? "user";
286
- const traceEnabled = options.trace !== false;
287
- const startMs = nowMs();
288
-
289
- // Open an OTel span lazily: only if tracing is enabled AND the active
290
- // tracer is recording. We create the span now (so attributes can be
291
- // set on it pre-end), but only end it when the user calls end().
292
- let span: Span | undefined;
293
- if (traceEnabled) {
294
- const tracer = getTracer();
295
- if (tracer.enabled) {
296
- span = tracer.startSpan(name, {
297
- kind: "internal",
298
- attributes: {
299
- "mandu.perf.category": category,
300
- ...(options.attributes ?? {}),
301
- },
302
- });
303
- }
304
- }
305
-
306
- let closed = false;
307
- return function endMark(): number {
308
- if (closed) return 0;
309
- closed = true;
310
- const durationMs = nowMs() - startMs;
311
- const entry: PerfMarkEntry = {
312
- name,
313
- durationMs,
314
- category,
315
- timestamp: Date.now(),
316
- };
317
- recordEntry(entry);
318
- if (span) {
319
- // Attach the measured duration so downstream consumers can read it
320
- // from trace attributes without having to compute `endTime - startTime`.
321
- span.setAttribute("mandu.perf.duration_ms", durationMs);
322
- span.setStatus("ok");
323
- span.end();
324
- }
325
- return durationMs;
326
- };
327
- }
328
-
329
- /**
330
- * Measure the wall-clock duration of `fn`. Propagates the resolved
331
- * value and rethrows errors (closing the mark with `status=error` on
332
- * the OTel span before rethrow).
333
- *
334
- * Works for both sync and async `fn`: the return type is always
335
- * `Promise<T>` because the common case is `await`ing an async body.
336
- * For a sync-only variant that preserves the sync return type, use
337
- * {@link time} directly.
338
- *
339
- * When perf is disabled, `fn` is invoked directly and the result is
340
- * returned unchanged — a single branch adds near-zero overhead.
341
- */
342
- export async function timeAsync<T>(
343
- name: string,
344
- fn: () => T | Promise<T>,
345
- options: PerfMarkOptions = {},
346
- ): Promise<T> {
347
- if (!isPerfEnabled()) return await fn();
348
-
349
- // When tracing is active, put the child span in the ALS scope so any
350
- // further `time()` / `timeAsync()` calls inside `fn` nest correctly.
351
- const category: PerfCategory = options.category ?? "user";
352
- const traceEnabled = options.trace !== false;
353
- const tracer = getTracer();
354
- const useSpanScope = traceEnabled && tracer.enabled;
355
-
356
- const startMs = nowMs();
357
- let span: Span | undefined;
358
- if (useSpanScope) {
359
- span = tracer.startSpan(name, {
360
- kind: "internal",
361
- attributes: {
362
- "mandu.perf.category": category,
363
- ...(options.attributes ?? {}),
364
- },
365
- });
366
- }
367
-
368
- const finalize = (status: "ok" | "error", errorMsg?: string): number => {
369
- const durationMs = nowMs() - startMs;
370
- recordEntry({
371
- name,
372
- durationMs,
373
- category,
374
- timestamp: Date.now(),
375
- });
376
- if (span) {
377
- span.setAttribute("mandu.perf.duration_ms", durationMs);
378
- span.setStatus(status, errorMsg);
379
- span.end();
380
- }
381
- return durationMs;
382
- };
383
-
384
- try {
385
- const result = span
386
- ? await runWithSpan(span, () => Promise.resolve(fn()))
387
- : await fn();
388
- finalize("ok");
389
- return result;
390
- } catch (err) {
391
- const msg = err instanceof Error ? err.message : String(err);
392
- finalize("error", msg);
393
- throw err;
394
- }
395
- }
396
-
397
- // ─── Scoped instance (createPerf) ────────────────────────────────────────
398
-
399
- /** Subset of the top-level API, bound to a specific enabled flag. */
400
- export interface Perf {
401
- readonly enabled: boolean;
402
- time(name: string, options?: PerfMarkOptions): PerfEndFn;
403
- timeAsync<T>(
404
- name: string,
405
- fn: () => T | Promise<T>,
406
- options?: PerfMarkOptions,
407
- ): Promise<T>;
408
- }
409
-
410
- export interface CreatePerfOptions {
411
- /**
412
- * Override the gate. When `false`, all operations are no-ops
413
- * regardless of `MANDU_PERF`. Useful to wire perf through a DI
414
- * container that already resolves the env var.
415
- */
416
- enabled?: boolean;
417
- }
418
-
419
- /**
420
- * Create a scoped perf instance. Equivalent to the module-level
421
- * {@link time} / {@link timeAsync} when `opts.enabled` is unset (defaults
422
- * to the global gate), but allows libraries / tests to force-disable
423
- * without mutating env.
424
- *
425
- * The scoped instance shares the global histogram — all marks flow
426
- * into the same `/_mandu/heap` buffer regardless of which factory
427
- * created them.
428
- */
429
- export function createPerf(opts: CreatePerfOptions = {}): Perf {
430
- const enabled = opts.enabled ?? isPerfEnabled();
431
- if (!enabled) {
432
- return {
433
- enabled: false,
434
- time: () => NOOP_END,
435
- timeAsync: async <T>(_n: string, fn: () => T | Promise<T>): Promise<T> => await fn(),
436
- };
437
- }
438
- return {
439
- enabled: true,
440
- time,
441
- timeAsync,
442
- };
443
- }
444
-
445
- // ─── Dashboard snapshot ──────────────────────────────────────────────────
446
-
447
- /**
448
- * Number of recent marks to include verbatim in {@link collectPerfSnapshot}.
449
- * Kept small so the `/_mandu/heap` payload stays tight.
450
- */
451
- const RECENT_SNAPSHOT_SIZE = 50;
452
-
453
- /**
454
- * Extract entries from the ring buffer in chronological order (oldest
455
- * first). Returns up to `limit` most-recent entries.
456
- */
457
- function readRing(limit: number): PerfMarkEntry[] {
458
- const out: PerfMarkEntry[] = [];
459
- if (!ringFull && ringHead === 0) return out;
460
- const total = ringFull ? PERF_BUFFER_LIMIT : ringHead;
461
- const start = ringFull ? ringHead : 0;
462
- for (let i = 0; i < total; i++) {
463
- const idx = (start + i) % PERF_BUFFER_LIMIT;
464
- const entry = ringBuffer[idx];
465
- if (entry) out.push(entry);
466
- }
467
- if (limit > 0 && out.length > limit) return out.slice(out.length - limit);
468
- return out;
469
- }
470
-
471
- /**
472
- * Build the perf dashboard snapshot for `/_mandu/heap`. Safe to call
473
- * even when perf is disabled — returns `{ enabled: false, ... }` with
474
- * empty arrays.
475
- */
476
- export function collectPerfSnapshot(): PerfDashboardSnapshot {
477
- const histogram: PerfHistogramEntry[] = [];
478
- for (const [name, stats] of perNameStats) {
479
- histogram.push(buildHistogramEntry(name, stats));
480
- }
481
- histogram.sort((a, b) => b.count - a.count);
482
-
483
- const bufferedCount = ringFull ? PERF_BUFFER_LIMIT : ringHead;
484
-
485
- return {
486
- enabled: isPerfEnabled(),
487
- totalCount: totalClosedCount,
488
- bufferedCount,
489
- bufferLimit: PERF_BUFFER_LIMIT,
490
- histogram,
491
- recent: readRing(RECENT_SNAPSHOT_SIZE),
492
- };
493
- }
494
-
495
- // ─── Framework bridge ────────────────────────────────────────────────────
496
-
497
- /**
498
- * Bridge hook — framework modules (`bundler/*`, `runtime/streaming-ssr`,
499
- * `cli/commands/dev`, etc.) can optionally record their own measurements
500
- * into the same histogram so the `/_mandu/heap` view shows a unified
501
- * picture. Callers should only invoke when `isPerfEnabled()` is already
502
- * known to be `true` (the check is duplicated here for safety).
503
- *
504
- * Framework code remains free to keep using `mark()`/`measure()` for
505
- * console logging — this bridge is additive.
506
- *
507
- * @internal — public only so bundler / runtime packages can reach it;
508
- * not part of the documented app-facing API.
509
- */
510
- export function recordFrameworkMeasurement(
511
- name: string,
512
- durationMs: number,
513
- category: Exclude<PerfCategory, "user"> = "framework",
514
- ): void {
515
- if (!isPerfEnabled()) return;
516
- if (!Number.isFinite(durationMs) || durationMs < 0) return;
517
- recordEntry({
518
- name,
519
- durationMs,
520
- category,
521
- timestamp: Date.now(),
522
- });
523
- }
524
-
525
- // ─── Test helpers ───────────────────────────────────────────────────────
526
-
527
- /**
528
- * Test-only: clear all buffered marks and per-name stats. Does NOT
529
- * touch the `MANDU_PERF` gate — pair with
530
- * {@link import("./index")._resetCacheForTesting} when you need both.
531
- *
532
- * @internal
533
- */
534
- export function _resetUserMarksForTesting(): void {
535
- for (let i = 0; i < PERF_BUFFER_LIMIT; i++) ringBuffer[i] = undefined;
536
- ringHead = 0;
537
- ringFull = false;
538
- totalClosedCount = 0;
539
- perNameStats.clear();
540
- }
541
-
542
- /**
543
- * Test-only: peek at the last closed mark (or undefined if the buffer
544
- * is empty). Useful for end-to-end assertions without exposing the
545
- * full ring buffer internals.
546
- *
547
- * @internal
548
- */
549
- export function _peekLastMarkForTesting(): PerfMarkEntry | undefined {
550
- if (!ringFull && ringHead === 0) return undefined;
551
- const lastIdx = (ringHead - 1 + PERF_BUFFER_LIMIT) % PERF_BUFFER_LIMIT;
552
- return ringBuffer[lastIdx];
553
- }
1
+ /**
2
+ * Phase 18.ψ — user-facing performance marks API
3
+ *
4
+ * Layered on top of the Phase 0 `@mandujs/core/perf` gate (`MANDU_PERF=1`),
5
+ * this module exposes a developer-friendly surface for measuring custom
6
+ * operations in application code. The existing module-level
7
+ * {@link import("./index").mark} + {@link import("./index").measure} remain
8
+ * unchanged for framework-internal callers; this file adds new primitives
9
+ * that close the ergonomic gap identified in user feedback:
10
+ *
11
+ * ```ts
12
+ * import { time, timeAsync, createPerf } from "@mandujs/core/perf";
13
+ *
14
+ * // 1. Basic scoped timing — returns a close-over end() function.
15
+ * const end = time("db-query");
16
+ * const rows = await db.query("SELECT ...");
17
+ * end(); // emits measurement
18
+ *
19
+ * // 2. Async scope — auto-closed, resolved value forwarded, errors rethrown.
20
+ * const data = await timeAsync("fetch-user", async () => {
21
+ * return await fetch("/api/user/123").then((r) => r.json());
22
+ * });
23
+ *
24
+ * // 3. Scoped instance (dependency-injection / opt-in per-module):
25
+ * const perf = createPerf({ enabled: process.env.MANDU_PERF === "1" });
26
+ * perf.time("foo")(); // no-op when disabled, zero overhead
27
+ * ```
28
+ *
29
+ * Design contracts:
30
+ * - **Zero overhead when gated off.** When {@link isPerfEnabled} is
31
+ * `false`, every function is a single branch that returns a
32
+ * stable no-op (see `NOOP_END`). No `Map` allocation, no histogram
33
+ * entry, no span creation, no console output.
34
+ * - **Gate is shared.** Framework-internal marks (HMR, SSR, bundler)
35
+ * and user marks honor the same `MANDU_PERF=1` switch. Users do not
36
+ * need a separate env var.
37
+ * - **OTel integration is automatic.** When Phase 18.θ's request tracer
38
+ * is enabled, every user mark becomes a child span under the active
39
+ * request span. Attribute `mandu.perf.category = "user" | "framework"
40
+ * | "bundler" | "ssr"` differentiates mark origin in the trace view.
41
+ * Opt-out via `{ trace: false }`.
42
+ * - **Bounded histogram buffer.** The last 1000 recorded measurements
43
+ * are retained in a ring buffer for `/_mandu/heap` inspection. Older
44
+ * entries are dropped in insertion order. Same cap as Phase 17's
45
+ * event-bus recent buffer.
46
+ * - **No new runtime deps.** Timing uses `Bun.nanoseconds()` when
47
+ * available, `performance.now()` elsewhere.
48
+ *
49
+ * Rationale vs `mark()` + `measure()` (framework-internal):
50
+ * - `mark(name)` records a start timestamp, then a later `measure(label,
51
+ * name)` call computes the delta. That signature is great for
52
+ * framework code where start/end happen in separate functions, but
53
+ * forces users to invent unique `name` strings and wire them through
54
+ * closures. The `time() => end()` shape is more natural for
55
+ * application code where both ends live in the same lexical scope.
56
+ * - Both APIs coexist; `time()` internally uses `Bun.nanoseconds()`
57
+ * directly and does NOT share the framework's `marks: Map<string,
58
+ * number>`. This avoids collisions between user-chosen names and
59
+ * framework markers (e.g., if a user named a span `"hmr:rebuild-total"`).
60
+ *
61
+ * @module perf/user-marks
62
+ */
63
+
64
+ import { isPerfEnabled } from "./index";
65
+ import { getTracer, getActiveSpan, runWithSpan, type Span } from "../observability/tracing";
66
+
67
+ // ─── Types ──────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Origin of a mark. Framework code sets this to the appropriate value
71
+ * when bridging `mark()`/`measure()` calls into the histogram. User
72
+ * `time()` / `timeAsync()` always records `"user"`.
73
+ */
74
+ export type PerfCategory = "user" | "framework" | "bundler" | "ssr";
75
+
76
+ /** Options accepted by {@link time} / {@link timeAsync} and {@link Perf.time}. */
77
+ export interface PerfMarkOptions {
78
+ /**
79
+ * Span category for histogram attribution + OTel attribute. Default:
80
+ * `"user"`. Framework code sets this explicitly.
81
+ */
82
+ category?: PerfCategory;
83
+ /**
84
+ * When `true` (default) and a tracer is active, the mark creates a
85
+ * child span on `end()` with attributes `mandu.perf.category` and
86
+ * duration. When `false`, histogram still records but no span emits.
87
+ */
88
+ trace?: boolean;
89
+ /**
90
+ * Extra attributes attached to the OTel child span (when
91
+ * `trace !== false` and a tracer is active). Ignored otherwise.
92
+ */
93
+ attributes?: Record<string, string | number | boolean>;
94
+ }
95
+
96
+ /** Zero-arg function returned by {@link time}. Idempotent — safe to call twice. */
97
+ export type PerfEndFn = () => number;
98
+
99
+ /** One entry in the bounded histogram buffer. */
100
+ export interface PerfMarkEntry {
101
+ /** Mark label. */
102
+ name: string;
103
+ /** Duration in milliseconds. */
104
+ durationMs: number;
105
+ /** Category as set by caller. */
106
+ category: PerfCategory;
107
+ /** Unix epoch millis when the mark was closed. */
108
+ timestamp: number;
109
+ }
110
+
111
+ /** Statistical summary per unique mark name. */
112
+ export interface PerfHistogramEntry {
113
+ name: string;
114
+ count: number;
115
+ mean: number;
116
+ /** 50th percentile. */
117
+ p50: number;
118
+ /** 95th percentile. */
119
+ p95: number;
120
+ /** 99th percentile. */
121
+ p99: number;
122
+ /** Minimum observed duration (ms). */
123
+ min: number;
124
+ /** Maximum observed duration (ms). */
125
+ max: number;
126
+ }
127
+
128
+ /** Shape of the `perf` block appended to `/_mandu/heap` JSON. */
129
+ export interface PerfDashboardSnapshot {
130
+ enabled: boolean;
131
+ /** Total closed marks since process start (NOT the buffer size). */
132
+ totalCount: number;
133
+ /** Current number of entries retained in the ring buffer. */
134
+ bufferedCount: number;
135
+ /** Max ring-buffer capacity. */
136
+ bufferLimit: number;
137
+ /** Per-name histogram, sorted by descending `count`. */
138
+ histogram: PerfHistogramEntry[];
139
+ /** The most-recent N marks (up to 50), newest last. */
140
+ recent: PerfMarkEntry[];
141
+ }
142
+
143
+ // ─── Timing primitive ───────────────────────────────────────────────────
144
+
145
+ /**
146
+ * High-resolution timestamp in milliseconds as a float. Prefers
147
+ * `Bun.nanoseconds()` (monotonic, integer-nanos precision) and falls
148
+ * back to `performance.now()` for non-Bun runtimes (tests that spawn
149
+ * Node, edge worker shims).
150
+ */
151
+ function nowMs(): number {
152
+ const bun = (globalThis as { Bun?: { nanoseconds?: () => number } }).Bun;
153
+ if (bun && typeof bun.nanoseconds === "function") {
154
+ return bun.nanoseconds() / 1_000_000;
155
+ }
156
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
157
+ return performance.now();
158
+ }
159
+ return Date.now();
160
+ }
161
+
162
+ // ─── Ring buffer + running stats ─────────────────────────────────────────
163
+
164
+ /**
165
+ * Max entries retained in the recent-marks ring buffer. Matches the
166
+ * Phase 17 event-bus recent buffer so operators get a consistent
167
+ * "last N" window across `/_mandu/heap` and `/_mandu/events/recent`.
168
+ */
169
+ export const PERF_BUFFER_LIMIT = 1000;
170
+
171
+ /**
172
+ * Per-name running state. We store the raw observations for percentile
173
+ * computation (bounded per-name at `PER_NAME_OBS_LIMIT` to cap memory
174
+ * across a long dev session). For O(1) scrape, we also maintain count /
175
+ * min / max / sum — percentiles are the only O(n log n) piece.
176
+ */
177
+ const PER_NAME_OBS_LIMIT = 500;
178
+
179
+ interface PerNameStats {
180
+ count: number;
181
+ sum: number;
182
+ min: number;
183
+ max: number;
184
+ /** Bounded window of observations (FIFO) for percentile calc. */
185
+ observations: number[];
186
+ /** Next write index into the ring. */
187
+ obsHead: number;
188
+ obsFull: boolean;
189
+ }
190
+
191
+ /** Ring buffer of most-recent `PerfMarkEntry` values (capacity `PERF_BUFFER_LIMIT`). */
192
+ const ringBuffer: (PerfMarkEntry | undefined)[] = new Array(PERF_BUFFER_LIMIT);
193
+ let ringHead = 0;
194
+ let ringFull = false;
195
+
196
+ /** Monotonic counter of closed marks since process start. */
197
+ let totalClosedCount = 0;
198
+
199
+ /** Per-name stats map. Only populated when perf is enabled. */
200
+ const perNameStats = new Map<string, PerNameStats>();
201
+
202
+ function getOrCreateStats(name: string): PerNameStats {
203
+ let s = perNameStats.get(name);
204
+ if (!s) {
205
+ s = {
206
+ count: 0,
207
+ sum: 0,
208
+ min: Number.POSITIVE_INFINITY,
209
+ max: Number.NEGATIVE_INFINITY,
210
+ observations: new Array(PER_NAME_OBS_LIMIT),
211
+ obsHead: 0,
212
+ obsFull: false,
213
+ };
214
+ perNameStats.set(name, s);
215
+ }
216
+ return s;
217
+ }
218
+
219
+ /** Append a closed mark to the ring buffer + update per-name stats. */
220
+ function recordEntry(entry: PerfMarkEntry): void {
221
+ // Ring buffer append.
222
+ ringBuffer[ringHead] = entry;
223
+ ringHead = (ringHead + 1) % PERF_BUFFER_LIMIT;
224
+ if (ringHead === 0) ringFull = true;
225
+ totalClosedCount++;
226
+
227
+ // Per-name stats update.
228
+ const s = getOrCreateStats(entry.name);
229
+ s.count++;
230
+ s.sum += entry.durationMs;
231
+ if (entry.durationMs < s.min) s.min = entry.durationMs;
232
+ if (entry.durationMs > s.max) s.max = entry.durationMs;
233
+ s.observations[s.obsHead] = entry.durationMs;
234
+ s.obsHead = (s.obsHead + 1) % PER_NAME_OBS_LIMIT;
235
+ if (s.obsHead === 0) s.obsFull = true;
236
+ }
237
+
238
+ /** Compute the p-th percentile (0..100) of a sorted array. */
239
+ function percentile(sorted: number[], p: number): number {
240
+ if (sorted.length === 0) return 0;
241
+ // Nearest-rank method — simple, deterministic, no interpolation
242
+ // artifacts on small samples.
243
+ const rank = Math.ceil((p / 100) * sorted.length);
244
+ const idx = Math.max(0, Math.min(sorted.length - 1, rank - 1));
245
+ return sorted[idx] ?? 0;
246
+ }
247
+
248
+ function buildHistogramEntry(name: string, s: PerNameStats): PerfHistogramEntry {
249
+ const obs = s.obsFull
250
+ ? s.observations.slice(0, PER_NAME_OBS_LIMIT)
251
+ : s.observations.slice(0, s.obsHead);
252
+ const sorted = [...obs].sort((a, b) => a - b);
253
+ return {
254
+ name,
255
+ count: s.count,
256
+ mean: s.count > 0 ? s.sum / s.count : 0,
257
+ p50: percentile(sorted, 50),
258
+ p95: percentile(sorted, 95),
259
+ p99: percentile(sorted, 99),
260
+ min: s.min === Number.POSITIVE_INFINITY ? 0 : s.min,
261
+ max: s.max === Number.NEGATIVE_INFINITY ? 0 : s.max,
262
+ };
263
+ }
264
+
265
+ // ─── No-op sentinel ─────────────────────────────────────────────────────
266
+
267
+ const NOOP_END: PerfEndFn = () => 0;
268
+
269
+ // ─── Public functions ───────────────────────────────────────────────────
270
+
271
+ /**
272
+ * Open a performance measurement. Returns an `end()` function that,
273
+ * when called, closes the measurement and records it in the histogram
274
+ * + OTel span (if tracing is active).
275
+ *
276
+ * When {@link isPerfEnabled} is `false`, returns a shared no-op end
277
+ * function. Zero allocations on the disabled path.
278
+ *
279
+ * Calling `end()` more than once is a no-op on the second call; the
280
+ * duration recorded is always the delta from the first `time()` call
281
+ * to the first `end()` call.
282
+ */
283
+ export function time(name: string, options: PerfMarkOptions = {}): PerfEndFn {
284
+ if (!isPerfEnabled()) return NOOP_END;
285
+ const category: PerfCategory = options.category ?? "user";
286
+ const traceEnabled = options.trace !== false;
287
+ const startMs = nowMs();
288
+
289
+ // Open an OTel span lazily: only if tracing is enabled AND the active
290
+ // tracer is recording. We create the span now (so attributes can be
291
+ // set on it pre-end), but only end it when the user calls end().
292
+ let span: Span | undefined;
293
+ if (traceEnabled) {
294
+ const tracer = getTracer();
295
+ if (tracer.enabled) {
296
+ span = tracer.startSpan(name, {
297
+ kind: "internal",
298
+ attributes: {
299
+ "mandu.perf.category": category,
300
+ ...(options.attributes ?? {}),
301
+ },
302
+ });
303
+ }
304
+ }
305
+
306
+ let closed = false;
307
+ return function endMark(): number {
308
+ if (closed) return 0;
309
+ closed = true;
310
+ const durationMs = nowMs() - startMs;
311
+ const entry: PerfMarkEntry = {
312
+ name,
313
+ durationMs,
314
+ category,
315
+ timestamp: Date.now(),
316
+ };
317
+ recordEntry(entry);
318
+ if (span) {
319
+ // Attach the measured duration so downstream consumers can read it
320
+ // from trace attributes without having to compute `endTime - startTime`.
321
+ span.setAttribute("mandu.perf.duration_ms", durationMs);
322
+ span.setStatus("ok");
323
+ span.end();
324
+ }
325
+ return durationMs;
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Measure the wall-clock duration of `fn`. Propagates the resolved
331
+ * value and rethrows errors (closing the mark with `status=error` on
332
+ * the OTel span before rethrow).
333
+ *
334
+ * Works for both sync and async `fn`: the return type is always
335
+ * `Promise<T>` because the common case is `await`ing an async body.
336
+ * For a sync-only variant that preserves the sync return type, use
337
+ * {@link time} directly.
338
+ *
339
+ * When perf is disabled, `fn` is invoked directly and the result is
340
+ * returned unchanged — a single branch adds near-zero overhead.
341
+ */
342
+ export async function timeAsync<T>(
343
+ name: string,
344
+ fn: () => T | Promise<T>,
345
+ options: PerfMarkOptions = {},
346
+ ): Promise<T> {
347
+ if (!isPerfEnabled()) return await fn();
348
+
349
+ // When tracing is active, put the child span in the ALS scope so any
350
+ // further `time()` / `timeAsync()` calls inside `fn` nest correctly.
351
+ const category: PerfCategory = options.category ?? "user";
352
+ const traceEnabled = options.trace !== false;
353
+ const tracer = getTracer();
354
+ const useSpanScope = traceEnabled && tracer.enabled;
355
+
356
+ const startMs = nowMs();
357
+ let span: Span | undefined;
358
+ if (useSpanScope) {
359
+ span = tracer.startSpan(name, {
360
+ kind: "internal",
361
+ attributes: {
362
+ "mandu.perf.category": category,
363
+ ...(options.attributes ?? {}),
364
+ },
365
+ });
366
+ }
367
+
368
+ const finalize = (status: "ok" | "error", errorMsg?: string): number => {
369
+ const durationMs = nowMs() - startMs;
370
+ recordEntry({
371
+ name,
372
+ durationMs,
373
+ category,
374
+ timestamp: Date.now(),
375
+ });
376
+ if (span) {
377
+ span.setAttribute("mandu.perf.duration_ms", durationMs);
378
+ span.setStatus(status, errorMsg);
379
+ span.end();
380
+ }
381
+ return durationMs;
382
+ };
383
+
384
+ try {
385
+ const result = span
386
+ ? await runWithSpan(span, () => Promise.resolve(fn()))
387
+ : await fn();
388
+ finalize("ok");
389
+ return result;
390
+ } catch (err) {
391
+ const msg = err instanceof Error ? err.message : String(err);
392
+ finalize("error", msg);
393
+ throw err;
394
+ }
395
+ }
396
+
397
+ // ─── Scoped instance (createPerf) ────────────────────────────────────────
398
+
399
+ /** Subset of the top-level API, bound to a specific enabled flag. */
400
+ export interface Perf {
401
+ readonly enabled: boolean;
402
+ time(name: string, options?: PerfMarkOptions): PerfEndFn;
403
+ timeAsync<T>(
404
+ name: string,
405
+ fn: () => T | Promise<T>,
406
+ options?: PerfMarkOptions,
407
+ ): Promise<T>;
408
+ }
409
+
410
+ export interface CreatePerfOptions {
411
+ /**
412
+ * Override the gate. When `false`, all operations are no-ops
413
+ * regardless of `MANDU_PERF`. Useful to wire perf through a DI
414
+ * container that already resolves the env var.
415
+ */
416
+ enabled?: boolean;
417
+ }
418
+
419
+ /**
420
+ * Create a scoped perf instance. Equivalent to the module-level
421
+ * {@link time} / {@link timeAsync} when `opts.enabled` is unset (defaults
422
+ * to the global gate), but allows libraries / tests to force-disable
423
+ * without mutating env.
424
+ *
425
+ * The scoped instance shares the global histogram — all marks flow
426
+ * into the same `/_mandu/heap` buffer regardless of which factory
427
+ * created them.
428
+ */
429
+ export function createPerf(opts: CreatePerfOptions = {}): Perf {
430
+ const enabled = opts.enabled ?? isPerfEnabled();
431
+ if (!enabled) {
432
+ return {
433
+ enabled: false,
434
+ time: () => NOOP_END,
435
+ timeAsync: async <T>(_n: string, fn: () => T | Promise<T>): Promise<T> => await fn(),
436
+ };
437
+ }
438
+ return {
439
+ enabled: true,
440
+ time,
441
+ timeAsync,
442
+ };
443
+ }
444
+
445
+ // ─── Dashboard snapshot ──────────────────────────────────────────────────
446
+
447
+ /**
448
+ * Number of recent marks to include verbatim in {@link collectPerfSnapshot}.
449
+ * Kept small so the `/_mandu/heap` payload stays tight.
450
+ */
451
+ const RECENT_SNAPSHOT_SIZE = 50;
452
+
453
+ /**
454
+ * Extract entries from the ring buffer in chronological order (oldest
455
+ * first). Returns up to `limit` most-recent entries.
456
+ */
457
+ function readRing(limit: number): PerfMarkEntry[] {
458
+ const out: PerfMarkEntry[] = [];
459
+ if (!ringFull && ringHead === 0) return out;
460
+ const total = ringFull ? PERF_BUFFER_LIMIT : ringHead;
461
+ const start = ringFull ? ringHead : 0;
462
+ for (let i = 0; i < total; i++) {
463
+ const idx = (start + i) % PERF_BUFFER_LIMIT;
464
+ const entry = ringBuffer[idx];
465
+ if (entry) out.push(entry);
466
+ }
467
+ if (limit > 0 && out.length > limit) return out.slice(out.length - limit);
468
+ return out;
469
+ }
470
+
471
+ /**
472
+ * Build the perf dashboard snapshot for `/_mandu/heap`. Safe to call
473
+ * even when perf is disabled — returns `{ enabled: false, ... }` with
474
+ * empty arrays.
475
+ */
476
+ export function collectPerfSnapshot(): PerfDashboardSnapshot {
477
+ const histogram: PerfHistogramEntry[] = [];
478
+ for (const [name, stats] of perNameStats) {
479
+ histogram.push(buildHistogramEntry(name, stats));
480
+ }
481
+ histogram.sort((a, b) => b.count - a.count);
482
+
483
+ const bufferedCount = ringFull ? PERF_BUFFER_LIMIT : ringHead;
484
+
485
+ return {
486
+ enabled: isPerfEnabled(),
487
+ totalCount: totalClosedCount,
488
+ bufferedCount,
489
+ bufferLimit: PERF_BUFFER_LIMIT,
490
+ histogram,
491
+ recent: readRing(RECENT_SNAPSHOT_SIZE),
492
+ };
493
+ }
494
+
495
+ // ─── Framework bridge ────────────────────────────────────────────────────
496
+
497
+ /**
498
+ * Bridge hook — framework modules (`bundler/*`, `runtime/streaming-ssr`,
499
+ * `cli/commands/dev`, etc.) can optionally record their own measurements
500
+ * into the same histogram so the `/_mandu/heap` view shows a unified
501
+ * picture. Callers should only invoke when `isPerfEnabled()` is already
502
+ * known to be `true` (the check is duplicated here for safety).
503
+ *
504
+ * Framework code remains free to keep using `mark()`/`measure()` for
505
+ * console logging — this bridge is additive.
506
+ *
507
+ * @internal — public only so bundler / runtime packages can reach it;
508
+ * not part of the documented app-facing API.
509
+ */
510
+ export function recordFrameworkMeasurement(
511
+ name: string,
512
+ durationMs: number,
513
+ category: Exclude<PerfCategory, "user"> = "framework",
514
+ ): void {
515
+ if (!isPerfEnabled()) return;
516
+ if (!Number.isFinite(durationMs) || durationMs < 0) return;
517
+ recordEntry({
518
+ name,
519
+ durationMs,
520
+ category,
521
+ timestamp: Date.now(),
522
+ });
523
+ }
524
+
525
+ // ─── Test helpers ───────────────────────────────────────────────────────
526
+
527
+ /**
528
+ * Test-only: clear all buffered marks and per-name stats. Does NOT
529
+ * touch the `MANDU_PERF` gate — pair with
530
+ * {@link import("./index")._resetCacheForTesting} when you need both.
531
+ *
532
+ * @internal
533
+ */
534
+ export function _resetUserMarksForTesting(): void {
535
+ for (let i = 0; i < PERF_BUFFER_LIMIT; i++) ringBuffer[i] = undefined;
536
+ ringHead = 0;
537
+ ringFull = false;
538
+ totalClosedCount = 0;
539
+ perNameStats.clear();
540
+ }
541
+
542
+ /**
543
+ * Test-only: peek at the last closed mark (or undefined if the buffer
544
+ * is empty). Useful for end-to-end assertions without exposing the
545
+ * full ring buffer internals.
546
+ *
547
+ * @internal
548
+ */
549
+ export function _peekLastMarkForTesting(): PerfMarkEntry | undefined {
550
+ if (!ringFull && ringHead === 0) return undefined;
551
+ const lastIdx = (ringHead - 1 + PERF_BUFFER_LIMIT) % PERF_BUFFER_LIMIT;
552
+ return ringBuffer[lastIdx];
553
+ }