@mtharrison/pkg-profiler 1.0.1

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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * HTML renderer for the profiling report.
3
+ *
4
+ * Generates a self-contained HTML file (inline CSS, no external dependencies)
5
+ * with a summary table and expandable Package > File > Function tree.
6
+ */
7
+
8
+ import type { ReportData, PackageEntry, FileEntry } from '../types.js';
9
+ import { formatTime, formatPct, escapeHtml } from './format.js';
10
+
11
+ function generateCss(): string {
12
+ return `
13
+ :root {
14
+ --bg: #fafbfc;
15
+ --text: #1a1a2e;
16
+ --muted: #8b8fa3;
17
+ --border: #e2e4ea;
18
+ --first-party-accent: #3b6cf5;
19
+ --first-party-bg: #eef2ff;
20
+ --dep-bg: #ffffff;
21
+ --bar-track: #e8eaed;
22
+ --bar-fill: #5b8def;
23
+ --bar-fill-fp: #3b6cf5;
24
+ --other-text: #a0a4b8;
25
+ --table-header-bg: #f4f5f7;
26
+ --shadow: 0 1px 3px rgba(0,0,0,0.06);
27
+ --radius: 6px;
28
+ --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace;
29
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
30
+ }
31
+
32
+ * { margin: 0; padding: 0; box-sizing: border-box; }
33
+
34
+ body {
35
+ font-family: var(--font-sans);
36
+ background: var(--bg);
37
+ color: var(--text);
38
+ line-height: 1.5;
39
+ padding: 2rem;
40
+ max-width: 960px;
41
+ margin: 0 auto;
42
+ }
43
+
44
+ h1 {
45
+ font-size: 1.5rem;
46
+ font-weight: 600;
47
+ margin-bottom: 0.25rem;
48
+ }
49
+
50
+ .meta {
51
+ color: var(--muted);
52
+ font-size: 0.85rem;
53
+ margin-bottom: 2rem;
54
+ }
55
+
56
+ h2 {
57
+ font-size: 1.1rem;
58
+ font-weight: 600;
59
+ margin-bottom: 0.75rem;
60
+ margin-top: 2rem;
61
+ }
62
+
63
+ /* Summary table */
64
+ table {
65
+ width: 100%;
66
+ border-collapse: collapse;
67
+ background: #fff;
68
+ border-radius: var(--radius);
69
+ box-shadow: var(--shadow);
70
+ overflow: hidden;
71
+ margin-bottom: 1rem;
72
+ }
73
+
74
+ th {
75
+ text-align: left;
76
+ background: var(--table-header-bg);
77
+ padding: 0.6rem 0.75rem;
78
+ font-size: 0.8rem;
79
+ font-weight: 600;
80
+ text-transform: uppercase;
81
+ letter-spacing: 0.04em;
82
+ color: var(--muted);
83
+ border-bottom: 1px solid var(--border);
84
+ }
85
+
86
+ td {
87
+ padding: 0.55rem 0.75rem;
88
+ border-bottom: 1px solid var(--border);
89
+ font-size: 0.9rem;
90
+ }
91
+
92
+ tr:last-child td { border-bottom: none; }
93
+
94
+ tr.first-party td:first-child {
95
+ border-left: 3px solid var(--first-party-accent);
96
+ padding-left: calc(0.75rem - 3px);
97
+ }
98
+
99
+ td.pkg-name { font-family: var(--font-mono); font-size: 0.85rem; }
100
+ td.numeric { text-align: right; font-family: var(--font-mono); font-size: 0.85rem; }
101
+
102
+ .bar-cell {
103
+ width: 30%;
104
+ padding-right: 1rem;
105
+ }
106
+
107
+ .bar-container {
108
+ display: flex;
109
+ align-items: center;
110
+ gap: 0.5rem;
111
+ }
112
+
113
+ .bar-track {
114
+ flex: 1;
115
+ height: 8px;
116
+ background: var(--bar-track);
117
+ border-radius: 4px;
118
+ overflow: hidden;
119
+ }
120
+
121
+ .bar-fill {
122
+ height: 100%;
123
+ border-radius: 4px;
124
+ background: var(--bar-fill);
125
+ min-width: 1px;
126
+ }
127
+
128
+ tr.first-party .bar-fill {
129
+ background: var(--bar-fill-fp);
130
+ }
131
+
132
+ .bar-pct {
133
+ font-family: var(--font-mono);
134
+ font-size: 0.8rem;
135
+ min-width: 3.5em;
136
+ text-align: right;
137
+ }
138
+
139
+ tr.other-row td {
140
+ color: var(--other-text);
141
+ font-style: italic;
142
+ }
143
+
144
+ /* Tree */
145
+ .tree {
146
+ background: #fff;
147
+ border-radius: var(--radius);
148
+ box-shadow: var(--shadow);
149
+ overflow: hidden;
150
+ }
151
+
152
+ details {
153
+ border-bottom: 1px solid var(--border);
154
+ }
155
+
156
+ details:last-child { border-bottom: none; }
157
+
158
+ details details { border-bottom: 1px solid var(--border); }
159
+ details details:last-child { border-bottom: none; }
160
+
161
+ summary {
162
+ cursor: pointer;
163
+ list-style: none;
164
+ padding: 0.6rem 0.75rem;
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 0.5rem;
168
+ font-size: 0.9rem;
169
+ user-select: none;
170
+ }
171
+
172
+ summary::-webkit-details-marker { display: none; }
173
+
174
+ summary::before {
175
+ content: '\\25B6';
176
+ font-size: 0.6rem;
177
+ color: var(--muted);
178
+ transition: transform 0.15s ease;
179
+ flex-shrink: 0;
180
+ }
181
+
182
+ details[open] > summary::before {
183
+ transform: rotate(90deg);
184
+ }
185
+
186
+ .tree-name {
187
+ font-family: var(--font-mono);
188
+ font-size: 0.85rem;
189
+ flex: 1;
190
+ }
191
+
192
+ .tree-label {
193
+ font-family: var(--font-sans);
194
+ font-size: 0.65rem;
195
+ font-weight: 600;
196
+ text-transform: uppercase;
197
+ letter-spacing: 0.04em;
198
+ padding: 0.1rem 0.35rem;
199
+ border-radius: 3px;
200
+ flex-shrink: 0;
201
+ }
202
+
203
+ .tree-label.pkg { background: #e8eaed; color: #555; }
204
+ .tree-label.file { background: #e8f0fe; color: #3b6cf5; }
205
+ .tree-label.fn { background: #f0f0f0; color: #777; }
206
+
207
+ .tree-stats {
208
+ font-family: var(--font-mono);
209
+ font-size: 0.8rem;
210
+ color: var(--muted);
211
+ flex-shrink: 0;
212
+ }
213
+
214
+ /* Level indentation */
215
+ .level-0 > summary { padding-left: 0.75rem; }
216
+ .level-1 > summary { padding-left: 2rem; }
217
+ .level-2 { padding: 0.45rem 0.75rem 0.45rem 3.25rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; }
218
+
219
+ /* First-party package highlight */
220
+ .fp-pkg > summary {
221
+ background: var(--first-party-bg);
222
+ border-left: 3px solid var(--first-party-accent);
223
+ }
224
+
225
+ .other-item {
226
+ padding: 0.45rem 0.75rem;
227
+ color: var(--other-text);
228
+ font-style: italic;
229
+ font-size: 0.85rem;
230
+ }
231
+
232
+ .other-item.indent-1 { padding-left: 2rem; }
233
+ .other-item.indent-2 { padding-left: 3.25rem; }
234
+
235
+ @media (max-width: 600px) {
236
+ body { padding: 1rem; }
237
+ .bar-cell { width: 25%; }
238
+ }
239
+ `;
240
+ }
241
+
242
+ function renderSummaryTable(
243
+ packages: PackageEntry[],
244
+ otherCount: number,
245
+ totalTimeUs: number,
246
+ ): string {
247
+ let rows = '';
248
+
249
+ for (const pkg of packages) {
250
+ const cls = pkg.isFirstParty ? 'first-party' : 'dependency';
251
+ const pctVal = totalTimeUs > 0 ? (pkg.timeUs / totalTimeUs) * 100 : 0;
252
+ rows += `
253
+ <tr class="${cls}">
254
+ <td class="pkg-name">${escapeHtml(pkg.name)}</td>
255
+ <td class="numeric">${escapeHtml(formatTime(pkg.timeUs))}</td>
256
+ <td class="bar-cell">
257
+ <div class="bar-container">
258
+ <div class="bar-track"><div class="bar-fill" style="width:${pctVal.toFixed(1)}%"></div></div>
259
+ <span class="bar-pct">${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))}</span>
260
+ </div>
261
+ </td>
262
+ <td class="numeric">${pkg.sampleCount}</td>
263
+ </tr>`;
264
+ }
265
+
266
+ if (otherCount > 0) {
267
+ rows += `
268
+ <tr class="other-row">
269
+ <td class="pkg-name">Other (${otherCount} items)</td>
270
+ <td class="numeric"></td>
271
+ <td class="bar-cell"></td>
272
+ <td class="numeric"></td>
273
+ </tr>`;
274
+ }
275
+
276
+ return `
277
+ <table>
278
+ <thead>
279
+ <tr>
280
+ <th>Package</th>
281
+ <th>Wall Time</th>
282
+ <th>% of Total</th>
283
+ <th>Samples</th>
284
+ </tr>
285
+ </thead>
286
+ <tbody>${rows}
287
+ </tbody>
288
+ </table>`;
289
+ }
290
+
291
+ function renderTree(
292
+ packages: PackageEntry[],
293
+ otherCount: number,
294
+ totalTimeUs: number,
295
+ ): string {
296
+ let html = '<div class="tree">';
297
+
298
+ for (const pkg of packages) {
299
+ const fpCls = pkg.isFirstParty ? ' fp-pkg' : '';
300
+ html += `<details class="level-0${fpCls}">`;
301
+ html += `<summary>`;
302
+ html += `<span class="tree-label pkg">pkg</span>`;
303
+ html += `<span class="tree-name">${escapeHtml(pkg.name)}</span>`;
304
+ html += `<span class="tree-stats">${escapeHtml(formatTime(pkg.timeUs))} &middot; ${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))} &middot; ${pkg.sampleCount} samples</span>`;
305
+ html += `</summary>`;
306
+
307
+ for (const file of pkg.files) {
308
+ html += `<details class="level-1">`;
309
+ html += `<summary>`;
310
+ html += `<span class="tree-label file">file</span>`;
311
+ html += `<span class="tree-name">${escapeHtml(file.name)}</span>`;
312
+ html += `<span class="tree-stats">${escapeHtml(formatTime(file.timeUs))} &middot; ${escapeHtml(formatPct(file.timeUs, totalTimeUs))} &middot; ${file.sampleCount} samples</span>`;
313
+ html += `</summary>`;
314
+
315
+ for (const fn of file.functions) {
316
+ html += `<div class="level-2">`;
317
+ html += `<span class="tree-label fn">fn</span> `;
318
+ html += `<span class="tree-name">${escapeHtml(fn.name)}</span>`;
319
+ html += ` <span class="tree-stats">${escapeHtml(formatTime(fn.timeUs))} &middot; ${escapeHtml(formatPct(fn.timeUs, totalTimeUs))} &middot; ${fn.sampleCount} samples</span>`;
320
+ html += `</div>`;
321
+ }
322
+
323
+ if (file.otherCount > 0) {
324
+ html += `<div class="other-item indent-2">Other (${file.otherCount} items)</div>`;
325
+ }
326
+
327
+ html += `</details>`;
328
+ }
329
+
330
+ if (pkg.otherCount > 0) {
331
+ html += `<div class="other-item indent-1">Other (${pkg.otherCount} items)</div>`;
332
+ }
333
+
334
+ html += `</details>`;
335
+ }
336
+
337
+ if (otherCount > 0) {
338
+ html += `<div class="other-item">Other (${otherCount} packages)</div>`;
339
+ }
340
+
341
+ html += '</div>';
342
+ return html;
343
+ }
344
+
345
+ /**
346
+ * Render a complete self-contained HTML report from aggregated profiling data.
347
+ */
348
+ export function renderHtml(data: ReportData): string {
349
+ const summaryTable = renderSummaryTable(data.packages, data.otherCount, data.totalTimeUs);
350
+ const tree = renderTree(data.packages, data.otherCount, data.totalTimeUs);
351
+ const totalFormatted = escapeHtml(formatTime(data.totalTimeUs));
352
+
353
+ return `<!DOCTYPE html>
354
+ <html lang="en">
355
+ <head>
356
+ <meta charset="utf-8">
357
+ <meta name="viewport" content="width=device-width, initial-scale=1">
358
+ <title>where-you-at report</title>
359
+ <style>${generateCss()}
360
+ </style>
361
+ </head>
362
+ <body>
363
+ <h1>where-you-at</h1>
364
+ <div class="meta">Generated ${escapeHtml(data.timestamp)} &middot; Total wall time: ${totalFormatted}</div>
365
+
366
+ <h2>Summary</h2>
367
+ ${summaryTable}
368
+
369
+ <h2>Details</h2>
370
+ ${tree}
371
+ </body>
372
+ </html>`;
373
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reporter orchestrator.
3
+ *
4
+ * Aggregates SampleStore data, renders HTML, writes file to cwd,
5
+ * and returns the file path.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import type { SampleStore } from './sample-store.js';
11
+ import { aggregate } from './reporter/aggregate.js';
12
+ import { renderHtml } from './reporter/html.js';
13
+
14
+ function generateFilename(): string {
15
+ const now = new Date();
16
+ const pad = (n: number) => String(n).padStart(2, '0');
17
+ const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
18
+ const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
19
+ return `where-you-at-${date}-${time}.html`;
20
+ }
21
+
22
+ function readProjectName(cwd: string): string {
23
+ try {
24
+ const raw = readFileSync(join(cwd, 'package.json'), 'utf-8');
25
+ const pkg = JSON.parse(raw) as { name?: string };
26
+ return pkg.name ?? 'app';
27
+ } catch {
28
+ return 'app';
29
+ }
30
+ }
31
+
32
+ export function generateReport(store: SampleStore, cwd?: string): string {
33
+ const resolvedCwd = cwd ?? process.cwd();
34
+ const projectName = readProjectName(resolvedCwd);
35
+ const data = aggregate(store, projectName);
36
+ const html = renderHtml(data);
37
+ const filename = generateFilename();
38
+ const filepath = join(resolvedCwd, filename);
39
+ writeFileSync(filepath, html, 'utf-8');
40
+ console.log(`Report written to ./${filename}`);
41
+ return filepath;
42
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Accumulates per-package wall time (microseconds) from the V8 CPU profiler.
3
+ *
4
+ * Data structure: nested Maps -- package -> file -> function -> microseconds.
5
+ * This naturally matches the package-first tree output that the reporter
6
+ * needs in Phase 3. O(1) lookups at each level, no serialization overhead.
7
+ *
8
+ * A parallel sampleCounts structure tracks raw sample counts (incremented by 1
9
+ * per record() call) for the summary table's "Sample count" column.
10
+ */
11
+ export class SampleStore {
12
+ private data = new Map<string, Map<string, Map<string, number>>>();
13
+ private counts = new Map<string, Map<string, Map<string, number>>>();
14
+ private internalCount = 0;
15
+ private internalSamples = 0;
16
+
17
+ /**
18
+ * Record a sample for a user-code frame.
19
+ * Accumulates deltaUs microseconds for the given (package, file, function) triple,
20
+ * and increments the parallel sample count by 1.
21
+ */
22
+ record(packageName: string, relativePath: string, functionId: string, deltaUs: number): void {
23
+ // Accumulate microseconds
24
+ let fileMap = this.data.get(packageName);
25
+ if (fileMap === undefined) {
26
+ fileMap = new Map<string, Map<string, number>>();
27
+ this.data.set(packageName, fileMap);
28
+ }
29
+
30
+ let funcMap = fileMap.get(relativePath);
31
+ if (funcMap === undefined) {
32
+ funcMap = new Map<string, number>();
33
+ fileMap.set(relativePath, funcMap);
34
+ }
35
+
36
+ funcMap.set(functionId, (funcMap.get(functionId) ?? 0) + deltaUs);
37
+
38
+ // Parallel sample count (always +1)
39
+ let countFileMap = this.counts.get(packageName);
40
+ if (countFileMap === undefined) {
41
+ countFileMap = new Map<string, Map<string, number>>();
42
+ this.counts.set(packageName, countFileMap);
43
+ }
44
+
45
+ let countFuncMap = countFileMap.get(relativePath);
46
+ if (countFuncMap === undefined) {
47
+ countFuncMap = new Map<string, number>();
48
+ countFileMap.set(relativePath, countFuncMap);
49
+ }
50
+
51
+ countFuncMap.set(functionId, (countFuncMap.get(functionId) ?? 0) + 1);
52
+ }
53
+
54
+ /** Record an internal/filtered frame (empty URL, eval, wasm, idle, etc). */
55
+ recordInternal(deltaUs: number): void {
56
+ this.internalCount += deltaUs;
57
+ this.internalSamples += 1;
58
+ }
59
+
60
+ /** Reset all accumulated data to a clean state. */
61
+ clear(): void {
62
+ this.data = new Map<string, Map<string, Map<string, number>>>();
63
+ this.counts = new Map<string, Map<string, Map<string, number>>>();
64
+ this.internalCount = 0;
65
+ this.internalSamples = 0;
66
+ }
67
+
68
+ /** Read-only access to the accumulated sample data (microseconds). */
69
+ get packages(): ReadonlyMap<string, Map<string, Map<string, number>>> {
70
+ return this.data;
71
+ }
72
+
73
+ /** Count of internal/filtered microseconds recorded. */
74
+ get internal(): number {
75
+ return this.internalCount;
76
+ }
77
+
78
+ /** Read-only access to the parallel sample counts. */
79
+ get sampleCountsByPackage(): ReadonlyMap<string, Map<string, Map<string, number>>> {
80
+ return this.counts;
81
+ }
82
+
83
+ /** Count of internal/filtered samples (raw count, not microseconds). */
84
+ get internalSampleCount(): number {
85
+ return this.internalSamples;
86
+ }
87
+ }
package/src/sampler.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { Session } from 'node:inspector/promises';
2
+ import type { Profiler } from 'node:inspector';
3
+ import { parseFrame } from './frame-parser.js';
4
+ import { PackageResolver } from './package-resolver.js';
5
+ import { generateReport } from './reporter.js';
6
+ import { SampleStore } from './sample-store.js';
7
+ import type { RawCallFrame } from './types.js';
8
+
9
+ // Module-level state -- lazy initialization
10
+ let session: Session | null = null;
11
+ let profiling = false;
12
+ const store = new SampleStore();
13
+ const resolver = new PackageResolver(process.cwd());
14
+
15
+ /**
16
+ * Start the V8 CPU profiler. If already profiling, this is a safe no-op.
17
+ */
18
+ export async function track(options?: { interval?: number }): Promise<void> {
19
+ if (profiling) return;
20
+
21
+ if (session === null) {
22
+ session = new Session();
23
+ session.connect();
24
+ }
25
+
26
+ await session.post('Profiler.enable');
27
+
28
+ if (options?.interval !== undefined) {
29
+ await session.post('Profiler.setSamplingInterval', {
30
+ interval: options.interval,
31
+ });
32
+ }
33
+
34
+ await session.post('Profiler.start');
35
+ profiling = true;
36
+ }
37
+
38
+ /**
39
+ * Stop the profiler (if running) and reset all accumulated sample data.
40
+ */
41
+ export async function clear(): Promise<void> {
42
+ if (profiling && session) {
43
+ await session.post('Profiler.stop');
44
+ await session.post('Profiler.disable');
45
+ profiling = false;
46
+ }
47
+ store.clear();
48
+ }
49
+
50
+ /**
51
+ * Stop the profiler, process collected samples through the data pipeline
52
+ * (parseFrame -> PackageResolver -> SampleStore), generate an HTML report,
53
+ * and return the file path. Resets the store after reporting (clean slate
54
+ * for next cycle).
55
+ *
56
+ * Returns the absolute path to the generated HTML file, or empty string
57
+ * if no samples were collected.
58
+ */
59
+ export async function report(): Promise<string> {
60
+ if (!profiling || !session) {
61
+ console.log('no samples collected');
62
+ return '';
63
+ }
64
+
65
+ const { profile } = await session.post('Profiler.stop');
66
+ await session.post('Profiler.disable');
67
+ profiling = false;
68
+
69
+ processProfile(profile);
70
+
71
+ let filepath = '';
72
+
73
+ if (store.packages.size > 0) {
74
+ filepath = generateReport(store);
75
+ } else {
76
+ console.log('no samples collected');
77
+ }
78
+
79
+ store.clear();
80
+ return filepath;
81
+ }
82
+
83
+ /**
84
+ * Process a V8 CPUProfile: walk each sample, parse the frame, resolve
85
+ * the package, and record into the store. Uses timeDeltas for wall-time
86
+ * microsecond accumulation.
87
+ */
88
+ function processProfile(profile: Profiler.Profile): void {
89
+ const nodeMap = new Map(profile.nodes.map((n) => [n.id, n]));
90
+ const samples = profile.samples ?? [];
91
+ const timeDeltas = profile.timeDeltas ?? [];
92
+
93
+ for (let i = 0; i < samples.length; i++) {
94
+ const node = nodeMap.get(samples[i]!);
95
+ if (!node) continue;
96
+
97
+ const deltaUs = timeDeltas[i] ?? 0;
98
+ const parsed = parseFrame(node.callFrame as RawCallFrame);
99
+
100
+ if (parsed.kind === 'user') {
101
+ if (parsed.filePath.startsWith('node:')) {
102
+ // Node.js built-in: attribute to "node (built-in)" package
103
+ const relativePath = parsed.filePath.slice(5);
104
+ store.record('node (built-in)', relativePath, parsed.functionId, deltaUs);
105
+ } else {
106
+ const { packageName, relativePath } = resolver.resolve(parsed.filePath);
107
+ store.record(packageName, relativePath, parsed.functionId, deltaUs);
108
+ }
109
+ } else {
110
+ store.recordInternal(deltaUs);
111
+ }
112
+ }
113
+ }
114
+
115
+ /** @internal -- exposed for testing only */
116
+ export function _getStore(): SampleStore {
117
+ return store;
118
+ }
package/src/types.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Raw V8 CPU profiler call frame matching Chrome DevTools Protocol Runtime.CallFrame.
3
+ */
4
+ export interface RawCallFrame {
5
+ functionName: string;
6
+ scriptId: string;
7
+ url: string;
8
+ lineNumber: number;
9
+ columnNumber: number;
10
+ }
11
+
12
+ /**
13
+ * Parsed and classified stack frame.
14
+ * Discriminated union on the `kind` field.
15
+ */
16
+ export type ParsedFrame =
17
+ | { kind: 'user'; filePath: string; functionId: string }
18
+ | { kind: 'internal' }
19
+ | { kind: 'eval' }
20
+ | { kind: 'wasm' };
21
+
22
+ /**
23
+ * Report data types for the aggregate/HTML pipeline.
24
+ */
25
+ export interface ReportEntry {
26
+ name: string;
27
+ timeUs: number; // accumulated microseconds
28
+ pct: number; // percentage of total (0-100)
29
+ sampleCount: number; // number of samples attributed
30
+ }
31
+
32
+ export interface FunctionEntry extends ReportEntry {}
33
+
34
+ export interface FileEntry extends ReportEntry {
35
+ functions: FunctionEntry[];
36
+ otherCount: number;
37
+ }
38
+
39
+ export interface PackageEntry extends ReportEntry {
40
+ isFirstParty: boolean;
41
+ files: FileEntry[];
42
+ otherCount: number;
43
+ }
44
+
45
+ export interface ReportData {
46
+ timestamp: string;
47
+ totalTimeUs: number;
48
+ packages: PackageEntry[];
49
+ otherCount: number;
50
+ }