@forgeax/engine-profiler 0.1.2

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 (118) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +164 -0
  3. package/bin/forgeax-engine-profiler.mjs +5 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/__tests__/browser-profile-detail-owner.test-d.d.ts +2 -0
  6. package/dist/__tests__/browser-profile-detail-owner.test-d.d.ts.map +1 -0
  7. package/dist/__tests__/browser-user-timing.test.d.ts +2 -0
  8. package/dist/__tests__/browser-user-timing.test.d.ts.map +1 -0
  9. package/dist/__tests__/cli-consumer.integration.test.d.ts +2 -0
  10. package/dist/__tests__/cli-consumer.integration.test.d.ts.map +1 -0
  11. package/dist/__tests__/cli-contract.test.d.ts +2 -0
  12. package/dist/__tests__/cli-contract.test.d.ts.map +1 -0
  13. package/dist/__tests__/cli-input-errors.test.d.ts +2 -0
  14. package/dist/__tests__/cli-input-errors.test.d.ts.map +1 -0
  15. package/dist/__tests__/cli-process.integration.test.d.ts +2 -0
  16. package/dist/__tests__/cli-process.integration.test.d.ts.map +1 -0
  17. package/dist/__tests__/compare.test.d.ts +2 -0
  18. package/dist/__tests__/compare.test.d.ts.map +1 -0
  19. package/dist/__tests__/comparison-side-owner.test-d.d.ts +2 -0
  20. package/dist/__tests__/comparison-side-owner.test-d.d.ts.map +1 -0
  21. package/dist/__tests__/consumer-smoke.integration.test.d.ts +2 -0
  22. package/dist/__tests__/consumer-smoke.integration.test.d.ts.map +1 -0
  23. package/dist/__tests__/generated-freshness.test.d.ts +2 -0
  24. package/dist/__tests__/generated-freshness.test.d.ts.map +1 -0
  25. package/dist/__tests__/model-determinism.test.d.ts +2 -0
  26. package/dist/__tests__/model-determinism.test.d.ts.map +1 -0
  27. package/dist/__tests__/model-query.test.d.ts +2 -0
  28. package/dist/__tests__/model-query.test.d.ts.map +1 -0
  29. package/dist/__tests__/overflow-long-tail.test.d.ts +2 -0
  30. package/dist/__tests__/overflow-long-tail.test.d.ts.map +1 -0
  31. package/dist/__tests__/profile-source-owner.test-d.d.ts +2 -0
  32. package/dist/__tests__/profile-source-owner.test-d.d.ts.map +1 -0
  33. package/dist/__tests__/profiler-error-owner.test-d.d.ts +2 -0
  34. package/dist/__tests__/profiler-error-owner.test-d.d.ts.map +1 -0
  35. package/dist/__tests__/published-entry.browser.test.d.ts +2 -0
  36. package/dist/__tests__/published-entry.browser.test.d.ts.map +1 -0
  37. package/dist/__tests__/recorder-contract.test.d.ts +2 -0
  38. package/dist/__tests__/recorder-contract.test.d.ts.map +1 -0
  39. package/dist/__tests__/schema-contract.test.d.ts +2 -0
  40. package/dist/__tests__/schema-contract.test.d.ts.map +1 -0
  41. package/dist/__tests__/schema-derived-union-owner.test.d.ts +2 -0
  42. package/dist/__tests__/schema-derived-union-owner.test.d.ts.map +1 -0
  43. package/dist/browser-user-timing.d.ts +24 -0
  44. package/dist/browser-user-timing.d.ts.map +1 -0
  45. package/dist/browser-user-timing.mjs +140 -0
  46. package/dist/browser-user-timing.mjs.map +1 -0
  47. package/dist/cli.d.ts +21 -0
  48. package/dist/cli.d.ts.map +1 -0
  49. package/dist/cli.mjs +7783 -0
  50. package/dist/cli.mjs.map +1 -0
  51. package/dist/clock.d.ts +5 -0
  52. package/dist/clock.d.ts.map +1 -0
  53. package/dist/compare.d.ts +42 -0
  54. package/dist/compare.d.ts.map +1 -0
  55. package/dist/errors.d.ts +64 -0
  56. package/dist/errors.d.ts.map +1 -0
  57. package/dist/generated/profile-capture.d.ts +44 -0
  58. package/dist/generated/profile-capture.d.ts.map +1 -0
  59. package/dist/index.d.ts +22 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.mjs +8111 -0
  62. package/dist/index.mjs.map +1 -0
  63. package/dist/model.d.ts +44 -0
  64. package/dist/model.d.ts.map +1 -0
  65. package/dist/profiler.d.ts +24 -0
  66. package/dist/profiler.d.ts.map +1 -0
  67. package/dist/recorder.d.ts +26 -0
  68. package/dist/recorder.d.ts.map +1 -0
  69. package/dist/schema.d.ts +13 -0
  70. package/dist/schema.d.ts.map +1 -0
  71. package/dist/tool-preview-profile.d.ts +33 -0
  72. package/dist/tool-preview-profile.d.ts.map +1 -0
  73. package/dist/types.d.ts +44 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/package.json +76 -0
  76. package/schema/profile-capture.schema.json +71 -0
  77. package/scripts/consume-smoke.mjs +17 -0
  78. package/scripts/generate-profile-types.mjs +75 -0
  79. package/src/__tests__/browser-profile-detail-owner.test-d.ts +26 -0
  80. package/src/__tests__/browser-user-timing.test.ts +127 -0
  81. package/src/__tests__/cli-consumer.integration.test.ts +113 -0
  82. package/src/__tests__/cli-contract.test.ts +115 -0
  83. package/src/__tests__/cli-input-errors.test.ts +210 -0
  84. package/src/__tests__/cli-process.integration.test.ts +154 -0
  85. package/src/__tests__/compare.test.ts +223 -0
  86. package/src/__tests__/comparison-side-owner.test-d.ts +28 -0
  87. package/src/__tests__/consumer-smoke.integration.test.ts +37 -0
  88. package/src/__tests__/fixtures/cli/invalid-version.json +18 -0
  89. package/src/__tests__/fixtures/cli/partial-capture.json +28 -0
  90. package/src/__tests__/fixtures/cli/valid-capture.json +43 -0
  91. package/src/__tests__/fixtures/profile-capture/invalid-boundary.json +144 -0
  92. package/src/__tests__/fixtures/profile-capture/model-input.json +54 -0
  93. package/src/__tests__/fixtures/profile-capture/sink-failure.json +30 -0
  94. package/src/__tests__/fixtures/profile-capture/valid-complete.json +43 -0
  95. package/src/__tests__/fixtures/profile-capture/valid-overflow.json +45 -0
  96. package/src/__tests__/generated-freshness.test.ts +14 -0
  97. package/src/__tests__/model-determinism.test.ts +70 -0
  98. package/src/__tests__/model-query.test.ts +128 -0
  99. package/src/__tests__/overflow-long-tail.test.ts +131 -0
  100. package/src/__tests__/profile-source-owner.test-d.ts +45 -0
  101. package/src/__tests__/profiler-error-owner.test-d.ts +64 -0
  102. package/src/__tests__/published-entry.browser.test.ts +27 -0
  103. package/src/__tests__/recorder-contract.test.ts +163 -0
  104. package/src/__tests__/schema-contract.test.ts +42 -0
  105. package/src/__tests__/schema-derived-union-owner.test.ts +57 -0
  106. package/src/browser-user-timing.ts +194 -0
  107. package/src/cli.ts +425 -0
  108. package/src/clock.ts +18 -0
  109. package/src/compare.ts +159 -0
  110. package/src/errors.ts +78 -0
  111. package/src/generated/profile-capture.ts +48 -0
  112. package/src/index.ts +54 -0
  113. package/src/model.ts +161 -0
  114. package/src/profiler.ts +201 -0
  115. package/src/recorder.ts +417 -0
  116. package/src/schema.ts +168 -0
  117. package/src/tool-preview-profile.ts +67 -0
  118. package/src/types.ts +56 -0
@@ -0,0 +1,417 @@
1
+ import type { ProfileClock } from './clock.js';
2
+ import { boundaryError, type ProfilerResult, stateError } from './errors.js';
3
+ import type {
4
+ ProfileCapture,
5
+ ProfilePhaseStart,
6
+ ProfileRecord,
7
+ ProfileSkipInput,
8
+ ProfileSource,
9
+ } from './types.js';
10
+
11
+ export interface RecorderLimits {
12
+ readonly frameLimit: number;
13
+ readonly eventLimit: number;
14
+ readonly detail?: ProfileDetail;
15
+ }
16
+
17
+ export type ProfileDetail = 'owner' | 'passes' | 'nested';
18
+
19
+ export type RecorderPhaseCatalog = Readonly<Record<ProfileSource, readonly string[]>>;
20
+
21
+ export interface RecorderSession {
22
+ readonly captureId: string;
23
+ readonly detail: ProfileDetail;
24
+ beginFrame(frameId: number): ProfilerResult<void>;
25
+ beginPhase(input: ProfilePhaseStart): ProfilerResult<void>;
26
+ beginPhase(source: ProfileSource, phase: string): ProfilerResult<void>;
27
+ endPhase(): ProfilerResult<void>;
28
+ recordSkip(input: ProfileSkipInput): ProfilerResult<void>;
29
+ endFrame(): ProfilerResult<void>;
30
+ finish(): ProfilerResult<ProfileCapture>;
31
+ }
32
+
33
+ const PHASE_RECORD_KIND = 1;
34
+ const SKIP_RECORD_KIND = 2;
35
+
36
+ interface RetainedRecordStore {
37
+ readonly kinds: Uint8Array;
38
+ readonly sources: ProfileSource[];
39
+ readonly phases: string[];
40
+ readonly frameIds: Float64Array;
41
+ readonly parentSources: Array<ProfileSource | undefined>;
42
+ readonly parentPhases: Array<string | undefined>;
43
+ readonly startMicros: Float64Array;
44
+ readonly endMicros: Float64Array;
45
+ readonly reasons: Array<string | undefined>;
46
+ count: number;
47
+ }
48
+
49
+ interface RecorderState {
50
+ readonly limits: RecorderLimits;
51
+ readonly phaseCatalog: RecorderPhaseCatalog;
52
+ readonly phaseArrays: Record<ProfileSource, readonly string[]>;
53
+ readonly phaseSets: Record<ProfileSource, ReadonlySet<string>>;
54
+ readonly recordStore: RetainedRecordStore;
55
+ readonly captureId: string;
56
+ readonly clock: ProfileClock;
57
+ readonly allocationReport: { profilerEventObjectAllocations: number } | undefined;
58
+ frameCount: number;
59
+ lastFrameId: number;
60
+ currentFrameId: number | undefined;
61
+ openSources: Array<ProfileSource | undefined>;
62
+ openPhaseNames: Array<string | undefined>;
63
+ openFrameIds: Array<number | undefined>;
64
+ openStartMicros: Array<number | undefined>;
65
+ openDepth: number;
66
+ droppedEventCount: number;
67
+ firstAffectedFrameId: number | undefined;
68
+ lastAffectedFrameId: number | undefined;
69
+ overflow: boolean;
70
+ finished: boolean;
71
+ }
72
+
73
+ const OK_VOID: ProfilerResult<void> = Object.freeze({ ok: true, value: undefined });
74
+
75
+ function positiveSafeInteger(value: unknown): value is number {
76
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
77
+ }
78
+
79
+ export function validateRecorderLimits(limits: RecorderLimits): ProfilerResult<void> {
80
+ if (!positiveSafeInteger(limits.frameLimit) || !positiveSafeInteger(limits.eventLimit)) {
81
+ return { ok: false, error: boundaryError(limits.frameLimit, limits.eventLimit) };
82
+ }
83
+ return OK_VOID;
84
+ }
85
+
86
+ function recordOverflow(state: RecorderState, frameId: number): void {
87
+ state.overflow = true;
88
+ state.droppedEventCount += 1;
89
+ state.firstAffectedFrameId ??= frameId;
90
+ state.lastAffectedFrameId = frameId;
91
+ }
92
+
93
+ function stateResult(state: RecorderState, operation: string): ProfilerResult<void> {
94
+ if (state.finished) return { ok: false, error: stateError(operation) };
95
+ return OK_VOID;
96
+ }
97
+
98
+ function sourceHasPhase(state: RecorderState, source: string, phase: string): boolean {
99
+ if (source !== 'app' && source !== 'render') return false;
100
+ const key = source;
101
+ const phases = state.phaseCatalog[key];
102
+ if (state.phaseArrays[key] !== phases) {
103
+ state.phaseArrays[key] = phases;
104
+ state.phaseSets[key] = new Set(phases);
105
+ }
106
+ return state.phaseSets[key].has(phase);
107
+ }
108
+
109
+ function sourceError(source: string, phase: string, frameId: number): ProfilerResult<never> {
110
+ return {
111
+ ok: false,
112
+ error: {
113
+ code: 'profile-source-failed',
114
+ expected: 'a phase declared by the source catalog',
115
+ hint: 'Use the source-owned phase catalog and retry the frame.',
116
+ detail: { source, phase, frameId },
117
+ },
118
+ };
119
+ }
120
+
121
+ function requireRetained<T>(value: T | undefined): T {
122
+ if (value === undefined) throw new Error('profiler record storage is incomplete');
123
+ return value;
124
+ }
125
+
126
+ function reserveRecord(state: RecorderState, frameId: number): number | undefined {
127
+ if (state.recordStore.count >= state.limits.eventLimit) {
128
+ recordOverflow(state, frameId);
129
+ return undefined;
130
+ }
131
+ const index = state.recordStore.count;
132
+ state.recordStore.count += 1;
133
+ return index;
134
+ }
135
+
136
+ function retainPhaseRecord(
137
+ state: RecorderState,
138
+ source: ProfileSource,
139
+ frameId: number,
140
+ phase: string,
141
+ parentSource: ProfileSource | undefined,
142
+ parentPhase: string | undefined,
143
+ startMicros: number,
144
+ endMicros: number,
145
+ ): void {
146
+ const index = reserveRecord(state, frameId);
147
+ if (index === undefined) return;
148
+ const records = state.recordStore;
149
+ records.kinds[index] = PHASE_RECORD_KIND;
150
+ records.sources[index] = source;
151
+ records.frameIds[index] = frameId;
152
+ records.phases[index] = phase;
153
+ records.parentSources[index] = parentSource;
154
+ records.parentPhases[index] = parentPhase;
155
+ records.startMicros[index] = startMicros;
156
+ records.endMicros[index] = endMicros;
157
+ }
158
+
159
+ function retainSkipRecord(
160
+ state: RecorderState,
161
+ source: ProfileSource,
162
+ frameId: number,
163
+ phase: string,
164
+ reason: string,
165
+ ): void {
166
+ const index = reserveRecord(state, frameId);
167
+ if (index === undefined) return;
168
+ const records = state.recordStore;
169
+ records.kinds[index] = SKIP_RECORD_KIND;
170
+ records.sources[index] = source;
171
+ records.frameIds[index] = frameId;
172
+ records.phases[index] = phase;
173
+ records.reasons[index] = reason;
174
+ }
175
+
176
+ function materializeRecords(state: RecorderState): ProfileRecord[] {
177
+ const retained = state.recordStore;
178
+ const records = new Array<ProfileRecord>(retained.count);
179
+ for (let index = 0; index < retained.count; index += 1) {
180
+ const kind = requireRetained(retained.kinds[index]);
181
+ const source = requireRetained(retained.sources[index]);
182
+ const frameId = requireRetained(retained.frameIds[index]);
183
+ const phase = requireRetained(retained.phases[index]);
184
+ if (kind === PHASE_RECORD_KIND) {
185
+ const parentSource = retained.parentSources[index];
186
+ const parentPhase = retained.parentPhases[index];
187
+ const startMicros = requireRetained(retained.startMicros[index]);
188
+ const endMicros = requireRetained(retained.endMicros[index]);
189
+ records[index] =
190
+ parentSource === undefined
191
+ ? {
192
+ kind: 'phase',
193
+ source,
194
+ frameId,
195
+ phase,
196
+ startMicros,
197
+ endMicros,
198
+ durationMicros: endMicros - startMicros,
199
+ }
200
+ : {
201
+ kind: 'phase',
202
+ source,
203
+ frameId,
204
+ phase,
205
+ parentSource,
206
+ parentPhase: requireRetained(parentPhase),
207
+ startMicros,
208
+ endMicros,
209
+ durationMicros: endMicros - startMicros,
210
+ };
211
+ } else {
212
+ records[index] = {
213
+ kind: 'skip',
214
+ source,
215
+ frameId,
216
+ phase,
217
+ reason: requireRetained(retained.reasons[index]),
218
+ };
219
+ }
220
+ if (state.allocationReport !== undefined) {
221
+ state.allocationReport.profilerEventObjectAllocations += 1;
222
+ }
223
+ }
224
+ return records;
225
+ }
226
+
227
+ function buildCapture(state: RecorderState): ProfileCapture {
228
+ const status = state.overflow
229
+ ? 'overflow'
230
+ : state.frameCount < state.limits.frameLimit || state.currentFrameId !== undefined
231
+ ? 'partial'
232
+ : 'complete';
233
+ const completeness = {
234
+ status,
235
+ retainedEventCount: state.recordStore.count,
236
+ droppedEventCount: state.droppedEventCount,
237
+ ...(status === 'partial' ? { incompleteReason: 'stopped-before-frame' } : {}),
238
+ ...(state.firstAffectedFrameId !== undefined
239
+ ? { firstAffectedFrameId: state.firstAffectedFrameId }
240
+ : {}),
241
+ ...(state.lastAffectedFrameId !== undefined
242
+ ? { lastAffectedFrameId: state.lastAffectedFrameId }
243
+ : {}),
244
+ } as ProfileCapture['completeness'];
245
+ return {
246
+ schemaVersion: '1.0',
247
+ captureId: state.captureId,
248
+ timeUnit: 'microseconds',
249
+ frameLimit: state.limits.frameLimit,
250
+ eventLimit: state.limits.eventLimit,
251
+ phaseCatalog: {
252
+ app: [...state.phaseCatalog.app],
253
+ render: [...state.phaseCatalog.render],
254
+ },
255
+ records: materializeRecords(state),
256
+ completeness,
257
+ };
258
+ }
259
+
260
+ export function createRecorder(
261
+ captureId: string,
262
+ limits: RecorderLimits,
263
+ clock: ProfileClock,
264
+ phaseCatalog: RecorderPhaseCatalog,
265
+ allocationReport?: { profilerEventObjectAllocations: number },
266
+ ): ProfilerResult<RecorderSession> {
267
+ const validLimits = validateRecorderLimits(limits);
268
+ if (!validLimits.ok) return validLimits;
269
+ const nowMicros = clock.nowMicros;
270
+ const state: RecorderState = {
271
+ limits,
272
+ phaseCatalog,
273
+ phaseArrays: {
274
+ app: phaseCatalog.app,
275
+ render: phaseCatalog.render,
276
+ },
277
+ phaseSets: {
278
+ app: new Set(phaseCatalog.app),
279
+ render: new Set(phaseCatalog.render),
280
+ },
281
+ recordStore: {
282
+ kinds: new Uint8Array(limits.eventLimit),
283
+ sources: new Array<ProfileSource>(limits.eventLimit),
284
+ phases: new Array<string>(limits.eventLimit),
285
+ frameIds: new Float64Array(limits.eventLimit),
286
+ parentSources: new Array<ProfileSource | undefined>(limits.eventLimit),
287
+ parentPhases: new Array<string | undefined>(limits.eventLimit),
288
+ startMicros: new Float64Array(limits.eventLimit),
289
+ endMicros: new Float64Array(limits.eventLimit),
290
+ reasons: new Array<string | undefined>(limits.eventLimit),
291
+ count: 0,
292
+ },
293
+ captureId,
294
+ clock,
295
+ allocationReport,
296
+ frameCount: 0,
297
+ lastFrameId: 0,
298
+ currentFrameId: undefined,
299
+ openSources: [],
300
+ openPhaseNames: [],
301
+ openFrameIds: [],
302
+ openStartMicros: [],
303
+ openDepth: 0,
304
+ droppedEventCount: 0,
305
+ firstAffectedFrameId: undefined,
306
+ lastAffectedFrameId: undefined,
307
+ overflow: false,
308
+ finished: false,
309
+ };
310
+
311
+ const session: RecorderSession = {
312
+ captureId,
313
+ detail: limits.detail ?? 'owner',
314
+ beginFrame(frameId) {
315
+ const stateCheck = stateResult(state, 'beginFrame');
316
+ if (!stateCheck.ok) return stateCheck;
317
+ if (
318
+ !positiveSafeInteger(frameId) ||
319
+ state.currentFrameId !== undefined ||
320
+ frameId <= state.lastFrameId
321
+ ) {
322
+ return { ok: false, error: stateError('beginFrame') };
323
+ }
324
+ state.lastFrameId = frameId;
325
+ state.currentFrameId = frameId;
326
+ state.frameCount += 1;
327
+ if (state.frameCount > state.limits.frameLimit)
328
+ recordOverflow(state, state.limits.frameLimit);
329
+ return OK_VOID;
330
+ },
331
+ beginPhase(inputOrSource: ProfilePhaseStart | ProfileSource, phaseName?: string) {
332
+ const stateCheck = stateResult(state, 'beginPhase');
333
+ if (!stateCheck.ok) return stateCheck;
334
+ const frameId = state.currentFrameId;
335
+ if (frameId === undefined) return { ok: false, error: stateError('beginPhase') };
336
+ const source = typeof inputOrSource === 'string' ? inputOrSource : inputOrSource.source;
337
+ const phase = typeof inputOrSource === 'string' ? phaseName : inputOrSource.phase;
338
+ if (phase === undefined) return { ok: false, error: stateError('beginPhase') };
339
+ if (!sourceHasPhase(state, source, phase)) return sourceError(source, phase, frameId);
340
+ const index = state.openDepth;
341
+ state.openSources[index] = source;
342
+ state.openPhaseNames[index] = phase;
343
+ state.openFrameIds[index] = frameId;
344
+ state.openStartMicros[index] = nowMicros();
345
+ state.openDepth += 1;
346
+ return OK_VOID;
347
+ },
348
+ endPhase() {
349
+ const stateCheck = stateResult(state, 'endPhase');
350
+ if (!stateCheck.ok) return stateCheck;
351
+ if (state.openDepth === 0) return { ok: false, error: stateError('endPhase') };
352
+ const index = state.openDepth - 1;
353
+ state.openDepth = index;
354
+ const source = state.openSources[index];
355
+ const phase = state.openPhaseNames[index];
356
+ const frameId = state.openFrameIds[index];
357
+ const startMicros = state.openStartMicros[index];
358
+ if (
359
+ source === undefined ||
360
+ phase === undefined ||
361
+ frameId === undefined ||
362
+ startMicros === undefined
363
+ ) {
364
+ return { ok: false, error: stateError('endPhase') };
365
+ }
366
+ const parentIndex = index - 1;
367
+ const parentSource = parentIndex >= 0 ? state.openSources[parentIndex] : undefined;
368
+ const parentPhase = parentIndex >= 0 ? state.openPhaseNames[parentIndex] : undefined;
369
+ if (!state.overflow) {
370
+ const endMicros = Math.max(startMicros, nowMicros());
371
+ retainPhaseRecord(
372
+ state,
373
+ source,
374
+ frameId,
375
+ phase,
376
+ parentSource,
377
+ parentPhase,
378
+ startMicros,
379
+ endMicros,
380
+ );
381
+ } else {
382
+ recordOverflow(state, frameId);
383
+ }
384
+ return OK_VOID;
385
+ },
386
+ recordSkip(input) {
387
+ const stateCheck = stateResult(state, 'recordSkip');
388
+ if (!stateCheck.ok) return stateCheck;
389
+ const frameId = state.currentFrameId;
390
+ if (frameId === undefined || state.openDepth > 0)
391
+ return { ok: false, error: stateError('recordSkip') };
392
+ if (!sourceHasPhase(state, input.source, input.phase))
393
+ return sourceError(input.source, input.phase, frameId);
394
+ if (state.overflow) recordOverflow(state, frameId);
395
+ else retainSkipRecord(state, input.source, frameId, input.phase, input.reason);
396
+ return OK_VOID;
397
+ },
398
+ endFrame() {
399
+ const stateCheck = stateResult(state, 'endFrame');
400
+ if (!stateCheck.ok) return stateCheck;
401
+ if (state.currentFrameId === undefined || state.openDepth > 0)
402
+ return { ok: false, error: stateError('endFrame') };
403
+ state.currentFrameId = undefined;
404
+ return OK_VOID;
405
+ },
406
+ finish() {
407
+ const stateCheck = stateResult(state, 'finish');
408
+ if (!stateCheck.ok) return { ok: false, error: stateCheck.error };
409
+ if (state.openDepth > 0 || state.currentFrameId !== undefined) {
410
+ return { ok: false, error: stateError('finish') };
411
+ }
412
+ state.finished = true;
413
+ return { ok: true, value: buildCapture(state) };
414
+ },
415
+ };
416
+ return { ok: true, value: session };
417
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,168 @@
1
+ // Ajv ships this entry as CommonJS. Use the named constructor so Vite's
2
+ // native ESM dev server does not assume a synthetic default export when the
3
+ // profiler is imported by the browser runtime.
4
+ import { Ajv2020 } from 'ajv/dist/2020.js';
5
+ import schemaDocument from '../schema/profile-capture.schema.json' with { type: 'json' };
6
+ import type { ProfileCapture, ProfileRecord } from './generated/profile-capture.js';
7
+ import type { ProfileResult } from './types.js';
8
+
9
+ export type ProfileArtifactError = {
10
+ readonly code: 'profile-artifact-invalid' | 'profile-artifact-incompatible';
11
+ readonly expected: string;
12
+ readonly hint: string;
13
+ readonly detail: { readonly path: string; readonly message: string };
14
+ };
15
+
16
+ const validator = new Ajv2020({ allErrors: true, strict: true }).compile(schemaDocument);
17
+
18
+ function error(
19
+ code: ProfileArtifactError['code'],
20
+ path: string,
21
+ message: string,
22
+ ): ProfileResult<never, ProfileArtifactError> {
23
+ return {
24
+ ok: false,
25
+ error: {
26
+ code,
27
+ expected: 'a schema-valid ProfileCapture v1 artifact',
28
+ hint: 'Regenerate or select a compatible ProfileCapture artifact before retrying.',
29
+ detail: { path, message },
30
+ },
31
+ };
32
+ }
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === 'object' && value !== null;
36
+ }
37
+
38
+ function validateSemanticRules(
39
+ value: Record<string, unknown>,
40
+ ): ProfileResult<ProfileCapture, ProfileArtifactError> {
41
+ const catalog = value.phaseCatalog as { app: string[]; render: string[] };
42
+ const records = value.records as ProfileRecord[];
43
+ let previousFrameId = 0;
44
+
45
+ for (const [index, record] of records.entries()) {
46
+ if (record.frameId < previousFrameId) {
47
+ return error(
48
+ 'profile-artifact-invalid',
49
+ `/records/${index}/frameId`,
50
+ 'frameId must not decrease',
51
+ );
52
+ }
53
+ previousFrameId = record.frameId;
54
+ if (!catalog[record.source].includes(record.phase)) {
55
+ return error(
56
+ 'profile-artifact-invalid',
57
+ `/records/${index}/phase`,
58
+ 'phase is absent from its source catalog',
59
+ );
60
+ }
61
+ if (record.kind === 'phase' && record.parentPhase !== undefined) {
62
+ const parentCatalog = catalog[record.parentSource ?? record.source];
63
+ if (!parentCatalog.includes(record.parentPhase)) {
64
+ return error(
65
+ 'profile-artifact-invalid',
66
+ `/records/${index}/parentPhase`,
67
+ 'parentPhase is absent from its source catalog',
68
+ );
69
+ }
70
+ if (record.parentPhase === record.phase) {
71
+ return error(
72
+ 'profile-artifact-invalid',
73
+ `/records/${index}/parentPhase`,
74
+ 'parentPhase must differ from phase',
75
+ );
76
+ }
77
+ } else if (record.kind === 'phase' && record.parentSource !== undefined) {
78
+ return error(
79
+ 'profile-artifact-invalid',
80
+ `/records/${index}/parentSource`,
81
+ 'parentSource requires parentPhase',
82
+ );
83
+ }
84
+ if (record.kind === 'phase') {
85
+ if (record.endMicros < record.startMicros) {
86
+ return error(
87
+ 'profile-artifact-invalid',
88
+ `/records/${index}/endMicros`,
89
+ 'endMicros must not precede startMicros',
90
+ );
91
+ }
92
+ if (record.durationMicros !== record.endMicros - record.startMicros) {
93
+ return error(
94
+ 'profile-artifact-invalid',
95
+ `/records/${index}/durationMicros`,
96
+ 'durationMicros must equal endMicros - startMicros',
97
+ );
98
+ }
99
+ }
100
+ }
101
+
102
+ const completeness = value.completeness as ProfileCapture['completeness'];
103
+ if (completeness.retainedEventCount !== records.length) {
104
+ return error(
105
+ 'profile-artifact-invalid',
106
+ '/completeness/retainedEventCount',
107
+ 'retainedEventCount must equal records.length',
108
+ );
109
+ }
110
+ if (completeness.status === 'complete') {
111
+ if (completeness.incompleteReason !== undefined || completeness.droppedEventCount !== 0) {
112
+ return error(
113
+ 'profile-artifact-invalid',
114
+ '/completeness',
115
+ 'complete captures cannot contain incomplete evidence',
116
+ );
117
+ }
118
+ }
119
+ if (completeness.status === 'partial' && completeness.incompleteReason === undefined) {
120
+ return error(
121
+ 'profile-artifact-invalid',
122
+ '/completeness/incompleteReason',
123
+ 'partial captures require incompleteReason',
124
+ );
125
+ }
126
+ if (completeness.status === 'overflow') {
127
+ if (completeness.droppedEventCount === 0) {
128
+ return error(
129
+ 'profile-artifact-invalid',
130
+ '/completeness/droppedEventCount',
131
+ 'overflow captures require dropped events',
132
+ );
133
+ }
134
+ if (
135
+ completeness.firstAffectedFrameId === undefined ||
136
+ completeness.lastAffectedFrameId === undefined
137
+ ) {
138
+ return error(
139
+ 'profile-artifact-invalid',
140
+ '/completeness',
141
+ 'overflow captures require affected frame bounds',
142
+ );
143
+ }
144
+ }
145
+ return { ok: true, value: value as unknown as ProfileCapture };
146
+ }
147
+
148
+ export function validateProfileCapture(
149
+ value: unknown,
150
+ ): ProfileResult<ProfileCapture, ProfileArtifactError> {
151
+ if (!isRecord(value)) return error('profile-artifact-invalid', '', 'artifact must be an object');
152
+ if (value.schemaVersion !== '1.0') {
153
+ return error(
154
+ 'profile-artifact-incompatible',
155
+ '/schemaVersion',
156
+ 'reader supports schema version 1.0 only',
157
+ );
158
+ }
159
+ if (!validator(value)) {
160
+ const issue = validator.errors?.[0];
161
+ return error(
162
+ 'profile-artifact-invalid',
163
+ issue?.instancePath ?? '',
164
+ issue?.message ?? 'schema validation failed',
165
+ );
166
+ }
167
+ return validateSemanticRules(value);
168
+ }
@@ -0,0 +1,67 @@
1
+ import { type ProfileCapture, validateProfileCapture } from './index.js';
2
+
3
+ export interface ToolPreviewProfileRef {
4
+ readonly kind: 'profile-capture';
5
+ readonly digest: string;
6
+ readonly uri: string;
7
+ }
8
+
9
+ export interface ToolPreviewProfileManifest {
10
+ readonly runId: string;
11
+ readonly captureId: string;
12
+ readonly profileDigest: string;
13
+ }
14
+
15
+ export type ToolPreviewProfileResult =
16
+ | {
17
+ readonly ok: true;
18
+ readonly value: {
19
+ readonly runId: string;
20
+ readonly captureId: string;
21
+ readonly profile: ProfileCapture;
22
+ };
23
+ }
24
+ | {
25
+ readonly ok: false;
26
+ readonly error: {
27
+ readonly code: string;
28
+ readonly expected: string;
29
+ readonly hint: string;
30
+ readonly detail: unknown;
31
+ };
32
+ };
33
+
34
+ export function consumeToolPreviewProfile(input: {
35
+ readonly manifest: ToolPreviewProfileManifest;
36
+ readonly profile: unknown;
37
+ readonly ref: ToolPreviewProfileRef;
38
+ }): ToolPreviewProfileResult {
39
+ if (input.ref.digest !== input.manifest.profileDigest) {
40
+ return fail('profile digest matches manifest identity');
41
+ }
42
+ const profileResult = validateProfileCapture(input.profile);
43
+ if (!profileResult.ok) return fail(profileResult.error.expected);
44
+ if (profileResult.value.captureId !== input.manifest.captureId) {
45
+ return fail('ProfileCapture.captureId matches manifest captureId');
46
+ }
47
+ return {
48
+ ok: true,
49
+ value: {
50
+ runId: input.manifest.runId,
51
+ captureId: input.manifest.captureId,
52
+ profile: profileResult.value,
53
+ },
54
+ };
55
+ }
56
+
57
+ function fail(expected: string): Extract<ToolPreviewProfileResult, { readonly ok: false }> {
58
+ return {
59
+ ok: false,
60
+ error: {
61
+ code: 'tool-preview-profile-mismatch',
62
+ expected,
63
+ hint: 'read the ProfileCapture through its owner schema and regenerate the manifest join',
64
+ detail: { expected },
65
+ },
66
+ };
67
+ }
package/src/types.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type {
2
+ ProfileCapture,
3
+ ProfileCompleteness,
4
+ ProfilePhaseRecord,
5
+ ProfileRecord,
6
+ ProfileSkipRecord,
7
+ ProfileSource,
8
+ } from './generated/profile-capture.js';
9
+
10
+ export type {
11
+ ProfileCapture,
12
+ ProfileCompleteness,
13
+ ProfilePhaseRecord,
14
+ ProfileRecord,
15
+ ProfileSkipRecord,
16
+ ProfileSource,
17
+ };
18
+
19
+ export type ProfileResult<T, E> =
20
+ | { readonly ok: true; readonly value: T }
21
+ | { readonly ok: false; readonly error: E };
22
+
23
+ export type ProfileSinkFailure = {
24
+ readonly code: string;
25
+ readonly expected?: string;
26
+ readonly hint?: string;
27
+ readonly detail?: { readonly message?: string };
28
+ };
29
+
30
+ export type ProfileSinkWriteResult =
31
+ | { readonly ok: true; readonly value?: undefined }
32
+ | { readonly ok: false; readonly error: ProfileSinkFailure };
33
+
34
+ export interface ProfileSink {
35
+ write(capture: ProfileCapture): void | ProfileSinkWriteResult;
36
+ }
37
+
38
+ export interface ProfileAllocationReport {
39
+ profilerEventObjectAllocations: number;
40
+ }
41
+
42
+ export interface ProfileFrameToken {
43
+ readonly captureId: string;
44
+ readonly frameId: number;
45
+ }
46
+
47
+ export interface ProfilePhaseStart {
48
+ readonly source: ProfileSource;
49
+ readonly phase: string;
50
+ }
51
+
52
+ export interface ProfileSkipInput {
53
+ readonly source: ProfileSource;
54
+ readonly phase: string;
55
+ readonly reason: string;
56
+ }