@mtharrison/pkg-profiler 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -12
- package/dist/index.cjs +95 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +95 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/dep-chain.ts +111 -0
- package/src/reporter/aggregate.ts +18 -1
- package/src/reporter/html.ts +16 -2
- package/src/sampler.ts +3 -1
- package/src/types.ts +1 -0
package/src/dep-chain.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve dependency chains for transitive npm packages.
|
|
3
|
+
*
|
|
4
|
+
* BFS through node_modules package.json files starting from the project's
|
|
5
|
+
* direct dependencies to find the shortest path to each profiled package.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
interface PkgJson {
|
|
12
|
+
dependencies?: Record<string, string>;
|
|
13
|
+
optionalDependencies?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readPkgJson(dir: string, cache: Map<string, PkgJson | null>): PkgJson | null {
|
|
17
|
+
if (cache.has(dir)) return cache.get(dir)!;
|
|
18
|
+
try {
|
|
19
|
+
const raw = readFileSync(join(dir, 'package.json'), 'utf-8');
|
|
20
|
+
const parsed = JSON.parse(raw) as PkgJson;
|
|
21
|
+
cache.set(dir, parsed);
|
|
22
|
+
return parsed;
|
|
23
|
+
} catch {
|
|
24
|
+
cache.set(dir, null);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function depsOf(pkg: PkgJson): string[] {
|
|
30
|
+
return [
|
|
31
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
32
|
+
...Object.keys(pkg.optionalDependencies ?? {}),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the shortest dependency chain from the project's direct deps
|
|
38
|
+
* to each of the given package names.
|
|
39
|
+
*
|
|
40
|
+
* @param projectRoot - Absolute path to the project root (contains package.json and node_modules/)
|
|
41
|
+
* @param packageNames - Set of package names that appeared in profiling data
|
|
42
|
+
* @param maxDepth - Maximum BFS depth to search (default 5)
|
|
43
|
+
* @returns Map from package name to chain array (e.g. `["express", "qs"]` means project -> express -> qs)
|
|
44
|
+
*/
|
|
45
|
+
export function resolveDependencyChains(
|
|
46
|
+
projectRoot: string,
|
|
47
|
+
packageNames: Set<string>,
|
|
48
|
+
maxDepth: number = 5,
|
|
49
|
+
): Map<string, string[]> {
|
|
50
|
+
const result = new Map<string, string[]>();
|
|
51
|
+
const cache = new Map<string, PkgJson | null>();
|
|
52
|
+
|
|
53
|
+
const rootPkg = readPkgJson(projectRoot, cache);
|
|
54
|
+
if (!rootPkg) return result;
|
|
55
|
+
|
|
56
|
+
const directDeps = new Set(depsOf(rootPkg));
|
|
57
|
+
|
|
58
|
+
// Mark direct deps — they have no chain
|
|
59
|
+
for (const name of packageNames) {
|
|
60
|
+
if (directDeps.has(name)) {
|
|
61
|
+
// Direct dep: no chain needed (empty array signals "direct")
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Only need to resolve transitive deps
|
|
66
|
+
const targets = new Set<string>();
|
|
67
|
+
for (const name of packageNames) {
|
|
68
|
+
if (!directDeps.has(name)) {
|
|
69
|
+
targets.add(name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (targets.size === 0) return result;
|
|
74
|
+
|
|
75
|
+
// BFS: queue entries are [packageName, chain-so-far]
|
|
76
|
+
const visited = new Set<string>();
|
|
77
|
+
const queue: Array<[string, string[]]> = [];
|
|
78
|
+
|
|
79
|
+
for (const dep of directDeps) {
|
|
80
|
+
queue.push([dep, [dep]]);
|
|
81
|
+
visited.add(dep);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let qi = 0;
|
|
85
|
+
while (qi < queue.length && targets.size > 0) {
|
|
86
|
+
const [pkgName, chain] = queue[qi++]!;
|
|
87
|
+
|
|
88
|
+
if (chain.length > maxDepth) continue;
|
|
89
|
+
|
|
90
|
+
// If this package is one of our targets, record the chain (excluding the target itself)
|
|
91
|
+
if (targets.has(pkgName)) {
|
|
92
|
+
result.set(pkgName, chain.slice(0, -1));
|
|
93
|
+
targets.delete(pkgName);
|
|
94
|
+
if (targets.size === 0) break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read this package's deps and enqueue
|
|
98
|
+
const pkgDir = join(projectRoot, 'node_modules', pkgName);
|
|
99
|
+
const pkg = readPkgJson(pkgDir, cache);
|
|
100
|
+
if (!pkg) continue;
|
|
101
|
+
|
|
102
|
+
for (const child of depsOf(pkg)) {
|
|
103
|
+
if (!visited.has(child)) {
|
|
104
|
+
visited.add(child);
|
|
105
|
+
queue.push([child, [...chain, child]]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
FileEntry,
|
|
14
14
|
FunctionEntry,
|
|
15
15
|
} from '../types.js';
|
|
16
|
+
import { resolveDependencyChains } from '../dep-chain.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Sum all microseconds in a SampleStore.
|
|
@@ -37,7 +38,7 @@ function sumStore(store: SampleStore): number {
|
|
|
37
38
|
* @param asyncStore - Optional SampleStore with async wait time data
|
|
38
39
|
* @returns ReportData with all packages sorted desc by time, no threshold applied
|
|
39
40
|
*/
|
|
40
|
-
export function aggregate(store: SampleStore, projectName: string, asyncStore?: SampleStore, globalAsyncTimeUs?: number, wallTimeUs?: number): ReportData {
|
|
41
|
+
export function aggregate(store: SampleStore, projectName: string, asyncStore?: SampleStore, globalAsyncTimeUs?: number, wallTimeUs?: number, projectRoot?: string): ReportData {
|
|
41
42
|
// 1. Calculate total user-attributed time
|
|
42
43
|
const totalTimeUs = sumStore(store);
|
|
43
44
|
// Per-entry percentages use the raw sum so they add up to 100%
|
|
@@ -232,6 +233,22 @@ export function aggregate(store: SampleStore, projectName: string, asyncStore?:
|
|
|
232
233
|
packages.push(pkgEntry);
|
|
233
234
|
}
|
|
234
235
|
|
|
236
|
+
// Resolve dependency chains for non-first-party packages
|
|
237
|
+
if (projectRoot) {
|
|
238
|
+
const thirdPartyNames = new Set(
|
|
239
|
+
packages.filter(p => !p.isFirstParty).map(p => p.name),
|
|
240
|
+
);
|
|
241
|
+
if (thirdPartyNames.size > 0) {
|
|
242
|
+
const chains = resolveDependencyChains(projectRoot, thirdPartyNames);
|
|
243
|
+
for (const pkg of packages) {
|
|
244
|
+
const chain = chains.get(pkg.name);
|
|
245
|
+
if (chain && chain.length > 0) {
|
|
246
|
+
pkg.depChain = chain;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
235
252
|
// Sort packages by timeUs descending
|
|
236
253
|
packages.sort((a, b) => b.timeUs - a.timeUs);
|
|
237
254
|
|
package/src/reporter/html.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
import type { ReportData, PackageEntry, FileEntry } from '../types.js';
|
|
10
10
|
import { formatTime, formatPct, escapeHtml } from './format.js';
|
|
11
11
|
|
|
12
|
+
function formatDepChain(depChain: string[] | undefined): string {
|
|
13
|
+
if (!depChain || depChain.length === 0) return '';
|
|
14
|
+
return `<span class="dep-chain">via ${depChain.map(n => escapeHtml(n)).join(' > ')}</span>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
function generateCss(): string {
|
|
13
18
|
return `
|
|
14
19
|
:root {
|
|
@@ -152,6 +157,7 @@ function generateCss(): string {
|
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
td.pkg-name { font-family: var(--font-mono); font-size: 0.85rem; }
|
|
160
|
+
.dep-chain { display: block; font-size: 0.7rem; color: var(--muted); font-family: var(--font-sans); }
|
|
155
161
|
td.numeric { text-align: right; font-family: var(--font-mono); font-size: 0.85rem; }
|
|
156
162
|
td.async-col { color: var(--bar-fill-async); }
|
|
157
163
|
|
|
@@ -381,6 +387,11 @@ function generateJs(): string {
|
|
|
381
387
|
.replace(/'/g, ''');
|
|
382
388
|
}
|
|
383
389
|
|
|
390
|
+
function depChainHtml(depChain) {
|
|
391
|
+
if (!depChain || depChain.length === 0) return '';
|
|
392
|
+
return '<span class="dep-chain">via ' + depChain.map(function(n) { return escapeHtml(n); }).join(' > ') + '</span>';
|
|
393
|
+
}
|
|
394
|
+
|
|
384
395
|
var sortBy = 'cpu';
|
|
385
396
|
|
|
386
397
|
function metricTime(entry) {
|
|
@@ -451,6 +462,7 @@ function generateJs(): string {
|
|
|
451
462
|
pct: pkg.pct,
|
|
452
463
|
isFirstParty: pkg.isFirstParty,
|
|
453
464
|
sampleCount: pkg.sampleCount,
|
|
465
|
+
depChain: pkg.depChain,
|
|
454
466
|
asyncTimeUs: pkg.asyncTimeUs,
|
|
455
467
|
asyncPct: pkg.asyncPct,
|
|
456
468
|
asyncOpCount: pkg.asyncOpCount,
|
|
@@ -472,7 +484,7 @@ function generateJs(): string {
|
|
|
472
484
|
var barVal = isAsync ? (pkg.asyncTimeUs || 0) : pkg.timeUs;
|
|
473
485
|
var pctVal = barTotal > 0 ? (barVal / barTotal) * 100 : 0;
|
|
474
486
|
rows += '<tr class="' + cls + '">' +
|
|
475
|
-
'<td class="pkg-name">' + escapeHtml(pkg.name) + '</td>' +
|
|
487
|
+
'<td class="pkg-name">' + escapeHtml(pkg.name) + depChainHtml(pkg.depChain) + '</td>' +
|
|
476
488
|
'<td class="numeric">' + escapeHtml(formatTime(pkg.timeUs)) + '</td>' +
|
|
477
489
|
'<td class="bar-cell"><div class="bar-container">' +
|
|
478
490
|
'<div class="bar-track"><div class="bar-fill" style="width:' + pctVal.toFixed(1) + '%"></div></div>' +
|
|
@@ -526,6 +538,7 @@ function generateJs(): string {
|
|
|
526
538
|
html += '<details class="level-0' + fpCls + '"><summary>';
|
|
527
539
|
html += '<span class="tree-label pkg">pkg</span>';
|
|
528
540
|
html += '<span class="tree-name">' + escapeHtml(pkg.name) + '</span>';
|
|
541
|
+
html += depChainHtml(pkg.depChain);
|
|
529
542
|
html += '<span class="tree-stats">' + escapeHtml(formatTime(pkgTime)) + ' · ' + escapeHtml(formatPct(pkgTime, pctTotal)) + ' · ' + pkg.sampleCount + ' samples</span>';
|
|
530
543
|
html += asyncStats(pkg);
|
|
531
544
|
html += '</summary>';
|
|
@@ -633,7 +646,7 @@ function renderSummaryTable(
|
|
|
633
646
|
const pctVal = totalTimeUs > 0 ? (pkg.timeUs / totalTimeUs) * 100 : 0;
|
|
634
647
|
rows += `
|
|
635
648
|
<tr class="${cls}">
|
|
636
|
-
<td class="pkg-name">${escapeHtml(pkg.name)}</td>
|
|
649
|
+
<td class="pkg-name">${escapeHtml(pkg.name)}${formatDepChain(pkg.depChain)}</td>
|
|
637
650
|
<td class="numeric">${escapeHtml(formatTime(pkg.timeUs))}</td>
|
|
638
651
|
<td class="bar-cell">
|
|
639
652
|
<div class="bar-container">
|
|
@@ -697,6 +710,7 @@ function renderTree(
|
|
|
697
710
|
html += `<summary>`;
|
|
698
711
|
html += `<span class="tree-label pkg">pkg</span>`;
|
|
699
712
|
html += `<span class="tree-name">${escapeHtml(pkg.name)}</span>`;
|
|
713
|
+
html += formatDepChain(pkg.depChain);
|
|
700
714
|
html += `<span class="tree-stats">${escapeHtml(formatTime(pkg.timeUs))} · ${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))} · ${pkg.sampleCount} samples</span>`;
|
|
701
715
|
if (hasAsync) html += formatAsyncStats(pkg);
|
|
702
716
|
html += `</summary>`;
|
package/src/sampler.ts
CHANGED
|
@@ -103,13 +103,15 @@ function stopSync(): PkgProfile {
|
|
|
103
103
|
|
|
104
104
|
processProfile(profile);
|
|
105
105
|
|
|
106
|
-
const
|
|
106
|
+
const cwd = process.cwd();
|
|
107
|
+
const projectName = readProjectName(cwd);
|
|
107
108
|
const data = aggregate(
|
|
108
109
|
store,
|
|
109
110
|
projectName,
|
|
110
111
|
asyncStore.packages.size > 0 ? asyncStore : undefined,
|
|
111
112
|
globalAsyncTimeUs,
|
|
112
113
|
wallTimeUs,
|
|
114
|
+
cwd,
|
|
113
115
|
);
|
|
114
116
|
store.clear();
|
|
115
117
|
asyncStore.clear();
|