@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
package/src/cli.ts ADDED
@@ -0,0 +1,425 @@
1
+ import { readFileSync, realpathSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { ProfileComparisonProjection } from './compare.js';
4
+ import { compareProfileCaptures } from './compare.js';
5
+ import type { ProfileModel } from './model.js';
6
+ import { buildProfileModel } from './model.js';
7
+ import type { ProfileSource } from './types.js';
8
+
9
+ export interface ProfilerCliResult {
10
+ readonly stdout: string;
11
+ readonly stderr: string;
12
+ readonly exitCode: number;
13
+ }
14
+
15
+ type CliCommand =
16
+ | { readonly kind: 'summary' }
17
+ | { readonly kind: 'frame'; readonly frameId: number }
18
+ | { readonly kind: 'phase'; readonly source: ProfileSource; readonly phase: string }
19
+ | { readonly kind: 'compare' };
20
+
21
+ type CliArguments = {
22
+ readonly command: CliCommand;
23
+ readonly filePath?: string;
24
+ readonly leftFilePath?: string;
25
+ readonly rightFilePath?: string;
26
+ };
27
+
28
+ type CliError = {
29
+ readonly code:
30
+ | 'cli-arguments-invalid'
31
+ | 'cli-input-empty'
32
+ | 'cli-input-file-read-failed'
33
+ | 'cli-input-invalid-json'
34
+ | 'cli-input-stdin-read-failed'
35
+ | 'cli-query-invalid';
36
+ readonly expected: string;
37
+ readonly hint: string;
38
+ readonly detail: {
39
+ readonly argument?: string;
40
+ readonly path?: string;
41
+ readonly side?: 'left' | 'right';
42
+ readonly message: string;
43
+ };
44
+ };
45
+
46
+ function output(value: unknown): string {
47
+ return `${JSON.stringify(value)}\n`;
48
+ }
49
+
50
+ function parsePositiveSafeInteger(value: string, argument: string): number | CliError {
51
+ if (!/^(?:0|[1-9][0-9]*)$/.test(value)) {
52
+ return {
53
+ code: 'cli-query-invalid',
54
+ expected: `${argument} is a positive safe integer`,
55
+ hint: `Pass a positive safe integer to ${argument}.`,
56
+ detail: { argument, message: `received ${value}` },
57
+ };
58
+ }
59
+ const parsed = Number(value);
60
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
61
+ return {
62
+ code: 'cli-query-invalid',
63
+ expected: `${argument} is a positive safe integer`,
64
+ hint: `Pass a positive safe integer to ${argument}.`,
65
+ detail: { argument, message: `received ${value}` },
66
+ };
67
+ }
68
+ return parsed;
69
+ }
70
+
71
+ type ParseState = {
72
+ kind: 'summary' | 'frame' | 'phase' | 'compare';
73
+ frameId: number | undefined;
74
+ source: ProfileSource | undefined;
75
+ phase: string | undefined;
76
+ filePath: string | undefined;
77
+ leftFilePath: string | undefined;
78
+ rightFilePath: string | undefined;
79
+ };
80
+
81
+ function consumeFlag(args: readonly string[], index: number, state: ParseState): number | CliError {
82
+ const argument = args[index] as string;
83
+ const value = args[index + 1];
84
+ if (value === undefined || value.startsWith('--')) {
85
+ return {
86
+ code: 'cli-arguments-invalid',
87
+ expected: `${argument} is followed by a value`,
88
+ hint: `Provide a value after ${argument}.`,
89
+ detail: { argument, message: 'missing argument value' },
90
+ };
91
+ }
92
+ if (argument === '--file') state.filePath = value;
93
+ if (argument === '--left-file') state.leftFilePath = value;
94
+ if (argument === '--right-file') state.rightFilePath = value;
95
+ if (argument === '--frame-id') {
96
+ const parsed = parsePositiveSafeInteger(value, argument);
97
+ if (typeof parsed !== 'number') return parsed;
98
+ state.frameId = parsed;
99
+ }
100
+ if (argument === '--source') {
101
+ if (value !== 'app' && value !== 'render') {
102
+ return {
103
+ code: 'cli-arguments-invalid',
104
+ expected: '--source is app or render',
105
+ hint: 'Pass app or render as the phase source.',
106
+ detail: { argument, message: `received ${value}` },
107
+ };
108
+ }
109
+ state.source = value;
110
+ }
111
+ if (argument === '--phase') state.phase = value;
112
+ return index + 2;
113
+ }
114
+
115
+ function validateCommand(state: ParseState): CliError | undefined {
116
+ const hasCompareFile = state.leftFilePath !== undefined || state.rightFilePath !== undefined;
117
+ if (state.kind === 'compare') {
118
+ if (
119
+ state.leftFilePath === undefined ||
120
+ state.rightFilePath === undefined ||
121
+ state.filePath !== undefined ||
122
+ state.frameId !== undefined ||
123
+ state.source !== undefined ||
124
+ state.phase !== undefined
125
+ ) {
126
+ return {
127
+ code: 'cli-arguments-invalid',
128
+ expected: 'compare requires --left-file and --right-file and no single-artifact selectors',
129
+ hint: 'Pass compare --left-file <path> --right-file <path>.',
130
+ detail: { message: 'invalid compare input selectors' },
131
+ };
132
+ }
133
+ return undefined;
134
+ }
135
+ if (hasCompareFile) {
136
+ return {
137
+ code: 'cli-arguments-invalid',
138
+ expected: 'compare is the only command that accepts --left-file and --right-file',
139
+ hint: 'Use compare --left-file <path> --right-file <path> for two artifacts.',
140
+ detail: { message: 'compare file selectors used with another command' },
141
+ };
142
+ }
143
+ if (
144
+ state.kind === 'summary' &&
145
+ (state.frameId !== undefined || state.source !== undefined || state.phase !== undefined)
146
+ ) {
147
+ return {
148
+ code: 'cli-arguments-invalid',
149
+ expected: 'summary has no frame or phase selectors',
150
+ hint: 'Use frame or phase when selecting a lower-level projection.',
151
+ detail: { message: 'summary received a lower-level selector' },
152
+ };
153
+ }
154
+ if (
155
+ state.kind === 'frame' &&
156
+ (state.frameId === undefined || state.source !== undefined || state.phase !== undefined)
157
+ ) {
158
+ return {
159
+ code: 'cli-arguments-invalid',
160
+ expected: 'frame requires --frame-id and no phase selectors',
161
+ hint: 'Pass frame --frame-id <positive-safe-integer>.',
162
+ detail: { message: 'invalid frame selector' },
163
+ };
164
+ }
165
+ if (
166
+ state.kind === 'phase' &&
167
+ (state.source === undefined || state.phase === undefined || state.frameId !== undefined)
168
+ ) {
169
+ return {
170
+ code: 'cli-arguments-invalid',
171
+ expected: 'phase requires --source and --phase and no frame selector',
172
+ hint: 'Pass phase --source <app|render> --phase <name>.',
173
+ detail: { message: 'invalid phase selector' },
174
+ };
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ function toCliArguments(state: ParseState): CliArguments {
180
+ if (state.kind === 'summary') {
181
+ return {
182
+ command: { kind: state.kind },
183
+ ...(state.filePath === undefined ? {} : { filePath: state.filePath }),
184
+ };
185
+ }
186
+ if (state.kind === 'frame') {
187
+ return {
188
+ command: { kind: state.kind, frameId: state.frameId as number },
189
+ ...(state.filePath === undefined ? {} : { filePath: state.filePath }),
190
+ };
191
+ }
192
+ if (state.kind === 'compare') {
193
+ return {
194
+ command: { kind: state.kind },
195
+ leftFilePath: state.leftFilePath as string,
196
+ rightFilePath: state.rightFilePath as string,
197
+ };
198
+ }
199
+ return {
200
+ command: {
201
+ kind: state.kind,
202
+ source: state.source as ProfileSource,
203
+ phase: state.phase as string,
204
+ },
205
+ ...(state.filePath === undefined ? {} : { filePath: state.filePath }),
206
+ };
207
+ }
208
+
209
+ function parseArguments(args: readonly string[]): CliArguments | CliError {
210
+ const state: ParseState = {
211
+ kind: 'summary',
212
+ frameId: undefined,
213
+ source: undefined,
214
+ phase: undefined,
215
+ filePath: undefined,
216
+ leftFilePath: undefined,
217
+ rightFilePath: undefined,
218
+ };
219
+ let index = 0;
220
+ const first = args[0];
221
+ if (first === 'summary' || first === 'frame' || first === 'phase' || first === 'compare') {
222
+ state.kind = first;
223
+ index = 1;
224
+ } else if (first !== undefined && !first.startsWith('--')) {
225
+ return {
226
+ code: 'cli-arguments-invalid',
227
+ expected: 'command is summary, frame, phase, or compare',
228
+ hint: 'Use summary, frame --frame-id <id>, phase --source <source> --phase <name>, or compare --left-file <path> --right-file <path>.',
229
+ detail: { argument: first, message: 'unknown command' },
230
+ };
231
+ }
232
+ while (index < args.length) {
233
+ const argument = args[index] as string;
234
+ if (
235
+ !['--file', '--left-file', '--right-file', '--frame-id', '--source', '--phase'].includes(
236
+ argument,
237
+ )
238
+ ) {
239
+ return {
240
+ code: 'cli-arguments-invalid',
241
+ expected: 'all arguments are declared CLI flags',
242
+ hint: 'Remove the unknown flag and use the documented summary, frame, phase, or compare form.',
243
+ detail: { argument, message: 'unknown argument' },
244
+ };
245
+ }
246
+ const nextIndex = consumeFlag(args, index, state);
247
+ if (typeof nextIndex !== 'number') return nextIndex;
248
+ index = nextIndex;
249
+ }
250
+ return validateCommand(state) ?? toCliArguments(state);
251
+ }
252
+
253
+ function readArtifact(filePath: string | undefined, stdin: string | CliError): string | CliError {
254
+ if (filePath !== undefined) {
255
+ try {
256
+ return readFileSync(filePath, 'utf8');
257
+ } catch (error) {
258
+ return {
259
+ code: 'cli-input-file-read-failed',
260
+ expected: 'the requested artifact file is readable',
261
+ hint: 'Check the artifact path and permissions, or omit --file to read stdin.',
262
+ detail: {
263
+ path: filePath,
264
+ message: error instanceof Error ? error.message : String(error),
265
+ },
266
+ };
267
+ }
268
+ }
269
+ if (isCliError(stdin)) return stdin;
270
+ if (stdin.trim().length === 0) {
271
+ return {
272
+ code: 'cli-input-empty',
273
+ expected: 'stdin contains one ProfileCapture JSON object',
274
+ hint: 'Pipe a ProfileCapture artifact to stdin or pass --file <path>.',
275
+ detail: { message: 'no artifact input was provided' },
276
+ };
277
+ }
278
+ return stdin;
279
+ }
280
+
281
+ function withSide(side: 'left' | 'right', error: CliError): CliError {
282
+ return { ...error, detail: { ...error.detail, side } };
283
+ }
284
+
285
+ function readCompareArtifact(filePath: string, side: 'left' | 'right'): unknown | CliError {
286
+ const input = readArtifact(filePath, '');
287
+ if (typeof input !== 'string') return withSide(side, input);
288
+ const artifact = parseArtifact(input);
289
+ return isCliError(artifact) ? withSide(side, artifact) : artifact;
290
+ }
291
+
292
+ function parseArtifact(input: string): unknown | CliError {
293
+ try {
294
+ return JSON.parse(input) as unknown;
295
+ } catch (error) {
296
+ return {
297
+ code: 'cli-input-invalid-json',
298
+ expected: 'input is one JSON object',
299
+ hint: 'Regenerate the artifact as JSON and retry.',
300
+ detail: { message: error instanceof Error ? error.message : String(error) },
301
+ };
302
+ }
303
+ }
304
+
305
+ function isCliError(value: unknown): value is CliError {
306
+ return (
307
+ typeof value === 'object' &&
308
+ value !== null &&
309
+ 'code' in value &&
310
+ typeof value.code === 'string' &&
311
+ value.code.startsWith('cli-')
312
+ );
313
+ }
314
+
315
+ function projectModel(
316
+ command: Exclude<CliCommand, { readonly kind: 'compare' }>,
317
+ model: ProfileModel,
318
+ ): Record<string, unknown> {
319
+ if (command.kind === 'summary') {
320
+ return { query: 'summary', ...model.summary, phases: model.phases };
321
+ }
322
+ if (command.kind === 'frame') {
323
+ return {
324
+ query: 'frame',
325
+ schemaVersion: model.summary.schemaVersion,
326
+ captureId: model.summary.captureId,
327
+ timeUnit: model.summary.timeUnit,
328
+ frameId: command.frameId,
329
+ completeness: model.completeness,
330
+ frame: model.frames.find((entry) => entry.frameId === command.frameId) ?? null,
331
+ };
332
+ }
333
+ const phaseSummary = model.phases.find(
334
+ (entry) => entry.source === command.source && entry.phase === command.phase,
335
+ );
336
+ return {
337
+ query: 'phase',
338
+ schemaVersion: model.summary.schemaVersion,
339
+ captureId: model.summary.captureId,
340
+ timeUnit: model.summary.timeUnit,
341
+ source: command.source,
342
+ phase: command.phase,
343
+ completeness: model.completeness,
344
+ phaseSummary: phaseSummary ?? null,
345
+ children: model.phases.filter(
346
+ (entry) => entry.parentSource === command.source && entry.parentPhase === command.phase,
347
+ ),
348
+ };
349
+ }
350
+
351
+ function projectComparison(value: ProfileComparisonProjection): Record<string, unknown> {
352
+ return { query: 'compare', ...value };
353
+ }
354
+
355
+ export function runProfilerCli(
356
+ args: readonly string[],
357
+ stdin: string | CliError,
358
+ ): ProfilerCliResult {
359
+ const parsedArguments = parseArguments(args);
360
+ if ('code' in parsedArguments) {
361
+ return { stdout: '', stderr: output({ error: parsedArguments }), exitCode: 2 };
362
+ }
363
+ if (parsedArguments.command.kind === 'compare') {
364
+ const left = readCompareArtifact(parsedArguments.leftFilePath as string, 'left');
365
+ if (isCliError(left)) return { stdout: '', stderr: output({ error: left }), exitCode: 2 };
366
+ const right = readCompareArtifact(parsedArguments.rightFilePath as string, 'right');
367
+ if (isCliError(right)) return { stdout: '', stderr: output({ error: right }), exitCode: 2 };
368
+ const comparison = compareProfileCaptures(left, right);
369
+ if (!comparison.ok) {
370
+ return { stdout: '', stderr: output({ error: comparison.error }), exitCode: 1 };
371
+ }
372
+ return {
373
+ stdout: output(projectComparison(comparison.value)),
374
+ stderr: '',
375
+ exitCode: 0,
376
+ };
377
+ }
378
+ const input = readArtifact(parsedArguments.filePath, stdin);
379
+ if (typeof input !== 'string') {
380
+ return { stdout: '', stderr: output({ error: input }), exitCode: 2 };
381
+ }
382
+ const artifact = parseArtifact(input);
383
+ if (isCliError(artifact)) return { stdout: '', stderr: output({ error: artifact }), exitCode: 2 };
384
+ const model = buildProfileModel(artifact);
385
+ if (!model.ok) return { stdout: '', stderr: output({ error: model.error }), exitCode: 1 };
386
+ return {
387
+ stdout: output(projectModel(parsedArguments.command, model.value)),
388
+ stderr: '',
389
+ exitCode: 0,
390
+ };
391
+ }
392
+
393
+ export function readCliInput(args: readonly string[], readStdin: () => string): string | CliError {
394
+ if (args.some((argument) => ['--file', '--left-file', '--right-file'].includes(argument))) {
395
+ return '';
396
+ }
397
+ try {
398
+ return readStdin();
399
+ } catch (error) {
400
+ return {
401
+ code: 'cli-input-stdin-read-failed',
402
+ expected: 'stdin can be read as one ProfileCapture JSON object',
403
+ hint: 'Provide readable ProfileCapture JSON on stdin or pass --file <path>.',
404
+ detail: { message: error instanceof Error ? error.message : String(error) },
405
+ };
406
+ }
407
+ }
408
+
409
+ export function main(): void {
410
+ const args = process.argv.slice(2);
411
+ const result = runProfilerCli(
412
+ args,
413
+ readCliInput(args, () => readFileSync(0, 'utf8')),
414
+ );
415
+ if (result.stdout !== '') process.stdout.write(result.stdout);
416
+ if (result.stderr !== '') process.stderr.write(result.stderr);
417
+ process.exitCode = result.exitCode;
418
+ }
419
+
420
+ if (
421
+ process.argv[1] !== undefined &&
422
+ fileURLToPath(import.meta.url) === realpathSync(process.argv[1])
423
+ ) {
424
+ main();
425
+ }
package/src/clock.ts ADDED
@@ -0,0 +1,18 @@
1
+ export interface ProfileClock {
2
+ nowMicros(): number;
3
+ }
4
+
5
+ export function createProfileClock(readMicros: () => number = defaultReadMicros): ProfileClock {
6
+ let lastMicros = 0;
7
+ return {
8
+ nowMicros() {
9
+ const currentMicros = readMicros();
10
+ if (currentMicros > lastMicros) lastMicros = currentMicros;
11
+ return lastMicros;
12
+ },
13
+ };
14
+ }
15
+
16
+ function defaultReadMicros(): number {
17
+ return Math.floor(globalThis.performance.now() * 1000);
18
+ }
package/src/compare.ts ADDED
@@ -0,0 +1,159 @@
1
+ import {
2
+ buildProfileModel,
3
+ type ProfileModel,
4
+ type ProfilePhaseModel,
5
+ type ProfileSummaryModel,
6
+ } from './model.js';
7
+ import type { ProfileArtifactError } from './schema.js';
8
+ import type { ProfileCapture, ProfileResult } from './types.js';
9
+
10
+ export interface ProfileComparisonPhaseIdentity {
11
+ readonly source: ProfilePhaseModel['source'];
12
+ readonly phase: string;
13
+ readonly parentSource?: ProfilePhaseModel['parentSource'];
14
+ readonly parentPhase?: string;
15
+ }
16
+
17
+ export interface ProfileComparisonPhaseFact {
18
+ readonly count: number;
19
+ readonly skipCount: number;
20
+ readonly p95DurationMicros: number | null;
21
+ }
22
+
23
+ export interface ProfileComparisonPhaseDelta {
24
+ readonly count?: number;
25
+ readonly skipCount?: number;
26
+ readonly p95DurationMicros?: number;
27
+ }
28
+
29
+ export interface ProfileComparisonPhaseRow {
30
+ readonly identity: ProfileComparisonPhaseIdentity;
31
+ readonly left?: ProfileComparisonPhaseFact;
32
+ readonly right?: ProfileComparisonPhaseFact;
33
+ readonly delta?: ProfileComparisonPhaseDelta;
34
+ }
35
+
36
+ export interface ProfileComparisonSide {
37
+ readonly summary: ProfileSummaryModel;
38
+ readonly completeness: ProfileCapture['completeness'];
39
+ }
40
+
41
+ export interface ProfileComparisonProjection {
42
+ readonly left: ProfileComparisonSide;
43
+ readonly right: ProfileComparisonSide;
44
+ readonly phases: readonly ProfileComparisonPhaseRow[];
45
+ }
46
+
47
+ export type ProfileComparisonError = ProfileArtifactError & {
48
+ readonly detail: ProfileArtifactError['detail'] & {
49
+ readonly side: 'left' | 'right';
50
+ };
51
+ };
52
+
53
+ function withSide(
54
+ side: ProfileComparisonError['detail']['side'],
55
+ error: ProfileArtifactError,
56
+ ): ProfileComparisonError {
57
+ return {
58
+ ...error,
59
+ detail: { ...error.detail, side },
60
+ };
61
+ }
62
+
63
+ function phaseKey(phase: ProfilePhaseModel): string {
64
+ return JSON.stringify([
65
+ phase.source,
66
+ phase.parentSource ?? null,
67
+ phase.parentPhase ?? null,
68
+ phase.phase,
69
+ ]);
70
+ }
71
+
72
+ function phaseIdentity(phase: ProfilePhaseModel): ProfileComparisonPhaseIdentity {
73
+ return {
74
+ source: phase.source,
75
+ phase: phase.phase,
76
+ ...(phase.parentSource === undefined ? {} : { parentSource: phase.parentSource }),
77
+ ...(phase.parentPhase === undefined ? {} : { parentPhase: phase.parentPhase }),
78
+ };
79
+ }
80
+
81
+ function phaseFact(phase: ProfilePhaseModel): ProfileComparisonPhaseFact {
82
+ return {
83
+ count: phase.count,
84
+ skipCount: phase.skipCount,
85
+ p95DurationMicros: phase.p95DurationMicros,
86
+ };
87
+ }
88
+
89
+ function phaseDelta(
90
+ left: ProfileComparisonPhaseFact | undefined,
91
+ right: ProfileComparisonPhaseFact | undefined,
92
+ comparableTimeUnit: boolean,
93
+ ): ProfileComparisonPhaseDelta | undefined {
94
+ if (left === undefined || right === undefined) return undefined;
95
+ const delta: {
96
+ count?: number;
97
+ skipCount?: number;
98
+ p95DurationMicros?: number;
99
+ } = {
100
+ count: right.count - left.count,
101
+ skipCount: right.skipCount - left.skipCount,
102
+ };
103
+ if (comparableTimeUnit && left.p95DurationMicros !== null && right.p95DurationMicros !== null) {
104
+ delta.p95DurationMicros = right.p95DurationMicros - left.p95DurationMicros;
105
+ }
106
+ return delta;
107
+ }
108
+
109
+ function projectPhases(
110
+ left: ProfileModel,
111
+ right: ProfileModel,
112
+ ): readonly ProfileComparisonPhaseRow[] {
113
+ const leftByKey = new Map(left.phases.map((phase) => [phaseKey(phase), phase]));
114
+ const rightByKey = new Map(right.phases.map((phase) => [phaseKey(phase), phase]));
115
+ const keys = [...new Set([...leftByKey.keys(), ...rightByKey.keys()])].sort();
116
+ const comparableTimeUnit = left.summary.timeUnit === right.summary.timeUnit;
117
+
118
+ return keys.map((key) => {
119
+ const leftPhase = leftByKey.get(key);
120
+ const rightPhase = rightByKey.get(key);
121
+ const leftFact = leftPhase === undefined ? undefined : phaseFact(leftPhase);
122
+ const rightFact = rightPhase === undefined ? undefined : phaseFact(rightPhase);
123
+ const delta = phaseDelta(leftFact, rightFact, comparableTimeUnit);
124
+ return {
125
+ identity: phaseIdentity(leftPhase ?? (rightPhase as ProfilePhaseModel)),
126
+ ...(leftFact === undefined ? {} : { left: leftFact }),
127
+ ...(rightFact === undefined ? {} : { right: rightFact }),
128
+ ...(delta === undefined ? {} : { delta }),
129
+ };
130
+ });
131
+ }
132
+
133
+ function projectModels(left: ProfileModel, right: ProfileModel): ProfileComparisonProjection {
134
+ return {
135
+ left: {
136
+ summary: left.summary,
137
+ completeness: left.completeness,
138
+ },
139
+ right: {
140
+ summary: right.summary,
141
+ completeness: right.completeness,
142
+ },
143
+ phases: projectPhases(left, right),
144
+ };
145
+ }
146
+
147
+ /** Compares two imported ProfileCapture artifacts without mutating either input. */
148
+ export function compareProfileCaptures(
149
+ left: unknown,
150
+ right: unknown,
151
+ ): ProfileResult<ProfileComparisonProjection, ProfileComparisonError> {
152
+ const leftModel = buildProfileModel(left);
153
+ if (!leftModel.ok) return { ok: false, error: withSide('left', leftModel.error) };
154
+
155
+ const rightModel = buildProfileModel(right);
156
+ if (!rightModel.ok) return { ok: false, error: withSide('right', rightModel.error) };
157
+
158
+ return { ok: true, value: projectModels(leftModel.value, rightModel.value) };
159
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { ProfileResult, ProfileSource } from './types.js';
2
+
3
+ /** Structured profiler failure with a code-specific detail payload. */
4
+ export type ProfilerError =
5
+ | {
6
+ readonly code: 'capture-boundary-invalid';
7
+ readonly expected: string;
8
+ readonly hint: string;
9
+ readonly detail: { readonly frameLimit: unknown; readonly eventLimit: unknown };
10
+ }
11
+ | {
12
+ readonly code: 'capture-already-active';
13
+ readonly expected: string;
14
+ readonly hint: string;
15
+ readonly detail: { readonly captureId: string };
16
+ }
17
+ | {
18
+ readonly code: 'profiler-not-enabled';
19
+ readonly expected: string;
20
+ readonly hint: string;
21
+ readonly detail: { readonly enabled: false };
22
+ }
23
+ | {
24
+ readonly code: 'phase-catalog-conflict';
25
+ readonly expected: string;
26
+ readonly hint: string;
27
+ readonly detail: {
28
+ readonly source: ProfileSource;
29
+ readonly expected: readonly string[];
30
+ readonly actual: readonly string[];
31
+ };
32
+ }
33
+ | {
34
+ readonly code: 'profile-source-failed';
35
+ readonly expected: string;
36
+ readonly hint: string;
37
+ readonly detail: {
38
+ readonly source: string;
39
+ readonly phase: string;
40
+ readonly frameId: number;
41
+ };
42
+ }
43
+ | {
44
+ readonly code: 'profile-sink-failed';
45
+ readonly expected: string;
46
+ readonly hint: string;
47
+ readonly detail: { readonly message: string };
48
+ }
49
+ | {
50
+ readonly code: 'capture-state-invalid';
51
+ readonly expected: string;
52
+ readonly hint: string;
53
+ readonly detail: { readonly operation: string };
54
+ };
55
+
56
+ /** Closed expected-failure vocabulary derived from the public profiler error owner. */
57
+ export type ProfilerErrorCode = ProfilerError['code'];
58
+
59
+ /** Result shape returned by profiler operations. */
60
+ export type ProfilerResult<T> = ProfileResult<T, ProfilerError>;
61
+
62
+ export function boundaryError(frameLimit: unknown, eventLimit: unknown): ProfilerError {
63
+ return {
64
+ code: 'capture-boundary-invalid',
65
+ expected: 'positive safe integer frameLimit and eventLimit',
66
+ hint: 'Retry with finite positive safe integer limits.',
67
+ detail: { frameLimit, eventLimit },
68
+ };
69
+ }
70
+
71
+ export function stateError(operation: string): ProfilerError {
72
+ return {
73
+ code: 'capture-state-invalid',
74
+ expected: 'an open capture session in the required phase state',
75
+ hint: 'Finish the open phase or frame before continuing, then retry the operation.',
76
+ detail: { operation },
77
+ };
78
+ }