@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.
@@ -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
 
@@ -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(' &gt; ')}</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, '&#39;');
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(' &gt; ') + '</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)) + ' &middot; ' + escapeHtml(formatPct(pkgTime, pctTotal)) + ' &middot; ' + 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))} &middot; ${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))} &middot; ${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 projectName = readProjectName(process.cwd());
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();
package/src/types.ts CHANGED
@@ -41,6 +41,7 @@ export interface FileEntry extends ReportEntry {
41
41
 
42
42
  export interface PackageEntry extends ReportEntry {
43
43
  isFirstParty: boolean;
44
+ depChain?: string[];
44
45
  files: FileEntry[];
45
46
  otherCount: number;
46
47
  }