packlyze 2.0.2 → 3.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.
package/README.md CHANGED
@@ -11,12 +11,20 @@ Advanced bundle analyzer with insights, recommendations, historical tracking, an
11
11
  ## 📊 Features
12
12
 
13
13
  - **Package Analysis**: Parse and analyze webpack, rollup, and esbuild stats files.
14
+ - **Package-Level Insights**: Group modules by npm package to identify heavy dependencies.
14
15
  - **Smart Recommendations**: Suggestions to optimize bundle size and structure.
15
16
  - **Tree-Shaking Detection**: Identify modules and patterns that block tree-shaking.
16
17
  - **Duplicate Detection**: Find and quantify duplicate modules with potential savings.
17
- - **Beautiful HTML Report**: Sleek, dark-themed, responsive report with high-level metrics and deep insights.
18
+ - **Chunk Analysis**: Analyze code-splitting efficiency and get optimization recommendations.
19
+ - **Unused Code Detection**: Identify potentially unused modules in your bundle.
20
+ - **Historical Tracking**: Track bundle size trends over time with `packlyze trends`.
21
+ - **Dependency Graph**: Generate Graphviz DOT files to visualize module dependencies.
22
+ - **Beautiful HTML Report**: Sleek, dark-themed, interactive report with search, filter, and sort.
18
23
  - **Baseline Comparison**: Compare current vs previous stats to see regressions and improvements.
24
+ - **Multiple Export Formats**: Export to HTML, CSV, or Markdown.
25
+ - **Config File Support**: Use `.packlyzerc` or `packlyze.config.json` for project defaults.
19
26
  - **CLI Tool**: Easy-to-use command-line interface with filters and CI-friendly thresholds.
27
+ - **Brotli Estimates**: Get Brotli compression size estimates (17% smaller than gzip).
20
28
  - **TypeScript Ready**: Full TypeScript support with type definitions.
21
29
 
22
30
  ## 🚀 Quick Start
@@ -201,6 +209,32 @@ packlyze analyze dist/stats.json \
201
209
  --no-html
202
210
  ```
203
211
 
212
+ ### Historical tracking
213
+
214
+ Track bundle size over time:
215
+
216
+ ```bash
217
+ # Automatically saves to .packlyze/history.json after each analysis
218
+ packlyze analyze stats.json
219
+
220
+ # View trends
221
+ packlyze trends
222
+
223
+ # View more entries
224
+ packlyze trends --limit 20
225
+ ```
226
+
227
+ ### Dependency graph
228
+
229
+ Generate a dependency graph visualization:
230
+
231
+ ```bash
232
+ packlyze analyze stats.json --dependency-graph graph.dot
233
+
234
+ # Render with Graphviz
235
+ dot -Tsvg graph.dot -o graph.svg
236
+ ```
237
+
204
238
  ## 🎯 Use Cases
205
239
 
206
240
  Common scenarios where Packlyze is helpful:
@@ -2,8 +2,11 @@ import { AnalysisResult } from '../types.js';
2
2
  export declare class Packlyze {
3
3
  private statsData;
4
4
  private baseDir;
5
- constructor(statsPath: string);
5
+ private verboseLog?;
6
+ constructor(statsPath: string, verboseLog?: (message: string) => void);
7
+ private log;
6
8
  private loadStats;
9
+ private validateStats;
7
10
  analyze(): Promise<AnalysisResult>;
8
11
  private extractBundleStats;
9
12
  private getTotalSize;
@@ -12,7 +15,18 @@ export declare class Packlyze {
12
15
  private getInitialBundleSize;
13
16
  private generateRecommendations;
14
17
  private detectTreeshakingIssues;
18
+ /**
19
+ * Extract package name from module path.
20
+ * Handles node_modules paths like:
21
+ * - node_modules/lodash/index.js -> lodash
22
+ * - node_modules/@types/node/index.d.ts -> @types/node
23
+ * - node_modules/react-dom/client.js -> react-dom
24
+ */
25
+ private extractPackageName;
15
26
  private findDuplicates;
27
+ private analyzePackages;
28
+ private detectUnusedModules;
29
+ private analyzeChunks;
16
30
  private calculateMetrics;
17
31
  }
18
32
  //# sourceMappingURL=packlyze.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"packlyze.d.ts","sourceRoot":"","sources":["../../src/analyzer/packlyze.ts"],"names":[],"mappings":"AAEA,OAAO,EAOL,cAAc,EACf,MAAM,aAAa,CAAC;AAErB,qBAAa,QAAQ;IACnB,OAAO,CAAC,SAAS,CAcV;IACP,OAAO,CAAC,OAAO,CAAS;gBAEZ,SAAS,EAAE,MAAM;IAQ7B,OAAO,CAAC,SAAS;IASX,OAAO,IAAI,OAAO,CAAC,cAAc,CAAC;IAiBxC,OAAO,CAAC,kBAAkB;IAwB1B,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,gBAAgB;IAKxB,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,uBAAuB;IAkD/B,OAAO,CAAC,uBAAuB;IAY/B,OAAO,CAAC,cAAc;IAyBtB,OAAO,CAAC,gBAAgB;CAkBzB"}
1
+ {"version":3,"file":"packlyze.d.ts","sourceRoot":"","sources":["../../src/analyzer/packlyze.ts"],"names":[],"mappings":"AAEA,OAAO,EAUL,cAAc,EACf,MAAM,aAAa,CAAC;AAErB,qBAAa,QAAQ;IACnB,OAAO,CAAC,SAAS,CAcV;IACP,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,UAAU,CAAC,CAA4B;gBAEnC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI;IASrE,OAAO,CAAC,GAAG;IAMX,OAAO,CAAC,SAAS;IA8BjB,OAAO,CAAC,aAAa;IA0Cf,OAAO,IAAI,OAAO,CAAC,cAAc,CAAC;IAuCxC,OAAO,CAAC,kBAAkB;IAqG1B,OAAO,CAAC,YAAY;IA2CpB,OAAO,CAAC,gBAAgB;IAuCxB,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,uBAAuB;IAkD/B,OAAO,CAAC,uBAAuB;IAmB/B;;;;;;OAMG;IACH,OAAO,CAAC,kBAAkB;IAc1B,OAAO,CAAC,cAAc;IAoCtB,OAAO,CAAC,eAAe;IAqCvB,OAAO,CAAC,mBAAmB;IAkD3B,OAAO,CAAC,aAAa;IAgErB,OAAO,CAAC,gBAAgB;CAuBzB"}
@@ -7,53 +7,200 @@ exports.Packlyze = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  class Packlyze {
10
- constructor(statsPath) {
10
+ constructor(statsPath, verboseLog) {
11
11
  this.statsData = {};
12
+ this.verboseLog = verboseLog;
12
13
  if (!fs_1.default.existsSync(statsPath)) {
13
14
  throw new Error(`Stats file not found: ${statsPath}`);
14
15
  }
15
16
  this.baseDir = path_1.default.dirname(statsPath);
16
17
  this.loadStats(statsPath);
17
18
  }
19
+ log(message) {
20
+ if (this.verboseLog) {
21
+ this.verboseLog(message);
22
+ }
23
+ }
18
24
  loadStats(statsPath) {
25
+ this.log('Reading stats file...');
19
26
  const content = fs_1.default.readFileSync(statsPath, 'utf-8');
27
+ this.log(`Stats file size: ${(content.length / 1024).toFixed(2)} KB`);
20
28
  try {
29
+ this.log('Parsing JSON...');
21
30
  this.statsData = JSON.parse(content);
31
+ this.log('Validating stats structure...');
32
+ this.validateStats();
33
+ // Count modules from different sources
34
+ const topLevelModules = this.statsData.modules?.length || 0;
35
+ const chunks = this.statsData.chunks || [];
36
+ let chunkModules = 0;
37
+ chunks.forEach((chunk) => {
38
+ if (Array.isArray(chunk.modules)) {
39
+ chunkModules += chunk.modules.length;
40
+ }
41
+ });
42
+ this.log(`Found ${topLevelModules} top-level modules, ${chunkModules} modules in chunks, ${chunks.length} chunks`);
22
43
  }
23
44
  catch (e) {
45
+ if (e instanceof Error && e.message.includes('Invalid stats')) {
46
+ throw e;
47
+ }
24
48
  throw new Error(`Invalid JSON in stats file: ${e}`);
25
49
  }
26
50
  }
51
+ validateStats() {
52
+ if (!this.statsData || typeof this.statsData !== 'object') {
53
+ throw new Error('Invalid stats: stats file must be a valid JSON object');
54
+ }
55
+ // Check for required structure (at least one of assets, modules, or chunks should exist)
56
+ const hasAssets = Array.isArray(this.statsData.assets);
57
+ const hasModules = Array.isArray(this.statsData.modules);
58
+ const hasChunks = Array.isArray(this.statsData.chunks);
59
+ if (!hasAssets && !hasModules && !hasChunks) {
60
+ throw new Error('Invalid stats: stats file must contain at least one of: assets, modules, or chunks arrays. ' +
61
+ 'Ensure you generated the stats file correctly (e.g., webpack --profile --json stats.json)');
62
+ }
63
+ // Validate modules if present
64
+ if (hasModules && Array.isArray(this.statsData.modules)) {
65
+ for (const module of this.statsData.modules) {
66
+ if (module && typeof module !== 'object') {
67
+ throw new Error('Invalid stats: all modules must be objects');
68
+ }
69
+ if (module && module.size !== undefined && (typeof module.size !== 'number' || module.size < 0)) {
70
+ throw new Error('Invalid stats: module sizes must be non-negative numbers');
71
+ }
72
+ }
73
+ }
74
+ // Validate assets if present
75
+ if (hasAssets && Array.isArray(this.statsData.assets)) {
76
+ for (const asset of this.statsData.assets) {
77
+ if (asset && typeof asset !== 'object') {
78
+ throw new Error('Invalid stats: all assets must be objects');
79
+ }
80
+ if (asset && asset.size !== undefined && (typeof asset.size !== 'number' || asset.size < 0)) {
81
+ throw new Error('Invalid stats: asset sizes must be non-negative numbers');
82
+ }
83
+ }
84
+ }
85
+ }
27
86
  async analyze() {
87
+ this.log('Extracting bundle statistics...');
28
88
  const bundleStats = this.extractBundleStats();
89
+ this.log('Analyzing packages...');
90
+ const packages = this.analyzePackages(bundleStats);
91
+ this.log('Detecting duplicate modules...');
92
+ const duplicates = this.findDuplicates(bundleStats);
93
+ this.log('Detecting tree-shaking issues...');
94
+ const treeshakingIssues = this.detectTreeshakingIssues(bundleStats);
95
+ this.log('Generating recommendations...');
29
96
  const recommendations = this.generateRecommendations(bundleStats);
30
- const treeshakingIssues = this.detectTreeshakingIssues();
31
- const duplicates = this.findDuplicates();
97
+ this.log('Calculating metrics...');
32
98
  const metrics = this.calculateMetrics(bundleStats);
99
+ this.log('Analyzing chunks...');
100
+ const chunkAnalysis = this.analyzeChunks(bundleStats);
101
+ this.log('Detecting unused modules...');
102
+ const unusedModules = this.detectUnusedModules(bundleStats);
103
+ this.log('Analysis complete!');
33
104
  return {
34
105
  bundleStats,
35
106
  recommendations,
36
107
  treeshakingIssues,
37
108
  duplicates,
109
+ packages,
110
+ chunkAnalysis,
111
+ unusedModules,
38
112
  metrics,
39
113
  timestamp: new Date().toISOString()
40
114
  };
41
115
  }
42
116
  extractBundleStats() {
43
117
  const assets = this.statsData.assets || [];
44
- const modules = (this.statsData.modules || []).map((m) => ({
45
- name: m.name || 'unknown',
46
- size: m.size || 0,
47
- gzipSize: m.gzipSize,
48
- percentage: (m.size || 0) / this.getTotalSize() * 100,
49
- reasons: Array.isArray(m.reasons)
50
- ? m.reasons.map((r) => typeof r === 'string' ? r : r.moduleName ?? '')
51
- : []
118
+ // Extract modules from top-level or from chunks (webpack 5+ format)
119
+ let modules = [];
120
+ // First, try top-level modules array
121
+ if (Array.isArray(this.statsData.modules) && this.statsData.modules.length > 0) {
122
+ modules = this.statsData.modules.map((m) => ({
123
+ name: m.name || 'unknown',
124
+ size: m.size || 0,
125
+ gzipSize: m.gzipSize,
126
+ percentage: 0, // Will calculate after we know total size
127
+ reasons: Array.isArray(m.reasons)
128
+ ? m.reasons.map((r) => typeof r === 'string' ? r : r.moduleName ?? '')
129
+ : []
130
+ }));
131
+ }
132
+ else {
133
+ // Extract modules from chunks (webpack 5+ format)
134
+ const moduleMap = new Map();
135
+ const chunks = this.statsData.chunks || [];
136
+ chunks.forEach((chunk) => {
137
+ if (Array.isArray(chunk.modules)) {
138
+ chunk.modules.forEach((m) => {
139
+ // Handle both object and string formats
140
+ let moduleName;
141
+ let moduleSize;
142
+ let moduleGzipSize;
143
+ let moduleReasons;
144
+ if (typeof m === 'string') {
145
+ // Module is just a name string
146
+ moduleName = m;
147
+ moduleSize = 0; // Size unknown, will be calculated from chunk or assets
148
+ moduleGzipSize = undefined;
149
+ moduleReasons = [];
150
+ }
151
+ else {
152
+ // Module is an object
153
+ moduleName = m.name || 'unknown';
154
+ moduleSize = m.size || 0;
155
+ moduleGzipSize = m.gzipSize;
156
+ moduleReasons = m.reasons;
157
+ }
158
+ if (!moduleMap.has(moduleName)) {
159
+ moduleMap.set(moduleName, {
160
+ name: moduleName,
161
+ size: moduleSize,
162
+ gzipSize: moduleGzipSize,
163
+ percentage: 0,
164
+ reasons: Array.isArray(moduleReasons)
165
+ ? moduleReasons.map((r) => typeof r === 'string' ? r : r.moduleName ?? '')
166
+ : []
167
+ });
168
+ }
169
+ else {
170
+ // If module appears in multiple chunks, use the maximum size (not sum)
171
+ // because the same module shouldn't be counted multiple times
172
+ const existing = moduleMap.get(moduleName);
173
+ existing.size = Math.max(existing.size, moduleSize);
174
+ if (moduleGzipSize) {
175
+ existing.gzipSize = existing.gzipSize ? Math.max(existing.gzipSize, moduleGzipSize) : moduleGzipSize;
176
+ }
177
+ // Merge reasons if available
178
+ if (Array.isArray(moduleReasons) && moduleReasons.length > 0) {
179
+ const existingReasons = new Set(existing.reasons);
180
+ moduleReasons.forEach((r) => {
181
+ const reasonStr = typeof r === 'string' ? r : r.moduleName ?? '';
182
+ if (reasonStr)
183
+ existingReasons.add(reasonStr);
184
+ });
185
+ existing.reasons = Array.from(existingReasons);
186
+ }
187
+ }
188
+ });
189
+ }
190
+ });
191
+ modules = Array.from(moduleMap.values());
192
+ }
193
+ // Calculate total size from assets or modules
194
+ const totalSize = this.getTotalSize() || modules.reduce((sum, m) => sum + (m.size || 0), 0);
195
+ // Calculate percentages
196
+ modules = modules.map((m) => ({
197
+ ...m,
198
+ percentage: totalSize > 0 ? ((m.size || 0) / totalSize) * 100 : 0
52
199
  }));
53
200
  return {
54
201
  name: this.statsData.name || 'bundle',
55
- size: this.getTotalSize(),
56
- gzipSize: this.getTotalGzipSize(),
202
+ size: totalSize,
203
+ gzipSize: this.getTotalGzipSize() || modules.reduce((sum, m) => sum + (m.gzipSize || 0), 0),
57
204
  modules: modules.sort((a, b) => b.size - a.size),
58
205
  chunks: this.extractChunks(),
59
206
  isInitialBySize: this.getInitialBundleSize(),
@@ -63,11 +210,71 @@ class Packlyze {
63
210
  }
64
211
  getTotalSize() {
65
212
  const assets = this.statsData.assets || [];
66
- return assets.reduce((sum, asset) => sum + (asset.size || 0), 0);
213
+ const assetSize = assets.reduce((sum, asset) => sum + (asset.size || 0), 0);
214
+ // If assets are empty or zero, try to calculate from modules
215
+ if (assetSize === 0) {
216
+ // Try top-level modules
217
+ if (Array.isArray(this.statsData.modules) && this.statsData.modules.length > 0) {
218
+ return this.statsData.modules.reduce((sum, m) => sum + (m.size || 0), 0);
219
+ }
220
+ // Try modules from chunks
221
+ const chunks = this.statsData.chunks || [];
222
+ let moduleSize = 0;
223
+ const seenModules = new Set();
224
+ chunks.forEach((chunk) => {
225
+ if (Array.isArray(chunk.modules)) {
226
+ chunk.modules.forEach((m) => {
227
+ const moduleName = m.name || 'unknown';
228
+ // Only count each module once (in case it appears in multiple chunks)
229
+ if (!seenModules.has(moduleName)) {
230
+ seenModules.add(moduleName);
231
+ moduleSize += (m.size || 0);
232
+ }
233
+ });
234
+ }
235
+ });
236
+ if (moduleSize > 0) {
237
+ return moduleSize;
238
+ }
239
+ // Try chunk sizes as fallback
240
+ const chunkSize = chunks.reduce((sum, c) => sum + (c.size || 0), 0);
241
+ if (chunkSize > 0) {
242
+ return chunkSize;
243
+ }
244
+ }
245
+ return assetSize;
67
246
  }
68
247
  getTotalGzipSize() {
69
248
  const assets = this.statsData.assets || [];
70
- return assets.reduce((sum, asset) => sum + (asset.gzipSize || 0), 0);
249
+ const assetGzipSize = assets.reduce((sum, asset) => sum + (asset.gzipSize || 0), 0);
250
+ // If assets are empty or zero, try to calculate from modules
251
+ if (assetGzipSize === 0) {
252
+ // Try top-level modules
253
+ if (Array.isArray(this.statsData.modules) && this.statsData.modules.length > 0) {
254
+ return this.statsData.modules.reduce((sum, m) => sum + (m.gzipSize || 0), 0);
255
+ }
256
+ // Try modules from chunks
257
+ const chunks = this.statsData.chunks || [];
258
+ let moduleGzipSize = 0;
259
+ const seenModules = new Set();
260
+ chunks.forEach((chunk) => {
261
+ if (Array.isArray(chunk.modules)) {
262
+ chunk.modules.forEach((m) => {
263
+ const moduleName = m.name || 'unknown';
264
+ if (!seenModules.has(moduleName)) {
265
+ seenModules.add(moduleName);
266
+ moduleGzipSize += (m.gzipSize || 0);
267
+ }
268
+ });
269
+ }
270
+ });
271
+ if (moduleGzipSize > 0) {
272
+ return moduleGzipSize;
273
+ }
274
+ // Try chunk gzip sizes as fallback
275
+ return chunks.reduce((sum, c) => sum + (c.gzipSize || 0), 0);
276
+ }
277
+ return assetGzipSize;
71
278
  }
72
279
  extractChunks() {
73
280
  return (this.statsData.chunks || []).map((chunk) => ({
@@ -110,7 +317,7 @@ class Packlyze {
110
317
  });
111
318
  }
112
319
  // Duplicate check
113
- const duplicates = this.findDuplicates();
320
+ const duplicates = this.findDuplicates(stats);
114
321
  if (duplicates.length > 0) {
115
322
  recommendations.push({
116
323
  severity: 'warning',
@@ -128,45 +335,210 @@ class Packlyze {
128
335
  }
129
336
  return recommendations;
130
337
  }
131
- detectTreeshakingIssues() {
338
+ detectTreeshakingIssues(stats) {
132
339
  const issues = [];
133
- const modules = this.statsData.modules || [];
134
- modules.forEach((m) => {
135
- if (typeof m.source === 'string' && (m.source.includes('module.exports') || m.source.includes('require('))) {
340
+ // Check modules from BundleStats (which includes extracted modules)
341
+ stats.modules.forEach((m) => {
342
+ // Try to get source from original stats data
343
+ const originalModule = Array.isArray(this.statsData.modules)
344
+ ? this.statsData.modules.find((om) => om.name === m.name)
345
+ : null;
346
+ if (originalModule && typeof originalModule.source === 'string' &&
347
+ (originalModule.source.includes('module.exports') || originalModule.source.includes('require('))) {
136
348
  issues.push(`${m.name}: Uses CommonJS - reduces tree-shaking effectiveness`);
137
349
  }
138
350
  });
139
351
  return issues.slice(0, 10); // Limit to top 10
140
352
  }
141
- findDuplicates() {
142
- const moduleMap = new Map();
353
+ /**
354
+ * Extract package name from module path.
355
+ * Handles node_modules paths like:
356
+ * - node_modules/lodash/index.js -> lodash
357
+ * - node_modules/@types/node/index.d.ts -> @types/node
358
+ * - node_modules/react-dom/client.js -> react-dom
359
+ */
360
+ extractPackageName(modulePath) {
361
+ if (!modulePath)
362
+ return null;
363
+ // Match node_modules packages
364
+ const nodeModulesMatch = modulePath.match(/node_modules[/\\](@[^/\\]+[/\\][^/\\]+|[^/\\]+)/);
365
+ if (nodeModulesMatch) {
366
+ return nodeModulesMatch[1].replace(/[/\\]/g, '/');
367
+ }
368
+ // For non-node_modules paths, use basename as fallback
369
+ // This helps catch duplicates in source code (e.g., utils/helper.js and components/helper.js)
370
+ return path_1.default.basename(modulePath);
371
+ }
372
+ findDuplicates(stats) {
373
+ // Group by package name (for node_modules) or basename (for source files)
374
+ const packageMap = new Map();
143
375
  const duplicates = [];
144
- (this.statsData.modules || []).forEach((module) => {
145
- const baseName = path_1.default.basename(module.name || '');
146
- if (!moduleMap.has(baseName)) {
147
- moduleMap.set(baseName, []);
376
+ stats.modules.forEach((module) => {
377
+ const moduleName = module.name || '';
378
+ const packageName = this.extractPackageName(moduleName) || 'unknown';
379
+ if (!packageMap.has(packageName)) {
380
+ packageMap.set(packageName, []);
148
381
  }
149
- moduleMap.get(baseName).push(module);
382
+ packageMap.get(packageName).push(module);
150
383
  });
151
- moduleMap.forEach((modules) => {
384
+ // Find actual duplicates (same package/module appearing multiple times)
385
+ packageMap.forEach((modules) => {
152
386
  if (modules.length > 1) {
153
- const totalSize = modules.reduce((s, m) => s + (m.size || 0), 0);
154
- const minSize = Math.min(...modules.map(m => m.size || 0));
155
- duplicates.push({
156
- names: modules.map(m => m.name),
157
- totalSize,
158
- savings: totalSize - minSize
159
- });
387
+ // Only consider it a duplicate if the modules have different full paths
388
+ // (same package imported from different locations)
389
+ const uniquePaths = new Set(modules.map(m => m.name));
390
+ if (uniquePaths.size > 1) {
391
+ const totalSize = modules.reduce((s, m) => s + (m.size || 0), 0);
392
+ const minSize = Math.min(...modules.map(m => m.size || 0));
393
+ duplicates.push({
394
+ names: modules.map(m => m.name),
395
+ totalSize,
396
+ savings: totalSize - minSize
397
+ });
398
+ }
160
399
  }
161
400
  });
162
401
  return duplicates.sort((a, b) => b.totalSize - a.totalSize).slice(0, 10);
163
402
  }
403
+ analyzePackages(stats) {
404
+ const packageMap = new Map();
405
+ const bundleTotalSize = stats.size;
406
+ stats.modules.forEach((module) => {
407
+ const packageName = this.extractPackageName(module.name);
408
+ if (!packageName)
409
+ return;
410
+ if (!packageMap.has(packageName)) {
411
+ packageMap.set(packageName, { modules: [], totalGzip: 0 });
412
+ }
413
+ const pkg = packageMap.get(packageName);
414
+ pkg.modules.push(module);
415
+ if (module.gzipSize) {
416
+ pkg.totalGzip += module.gzipSize;
417
+ }
418
+ });
419
+ const packages = [];
420
+ packageMap.forEach((pkg, name) => {
421
+ const packageTotalSize = pkg.modules.reduce((sum, m) => sum + m.size, 0);
422
+ packages.push({
423
+ name,
424
+ totalSize: packageTotalSize,
425
+ gzipSize: pkg.totalGzip > 0 ? pkg.totalGzip : undefined,
426
+ moduleCount: pkg.modules.length,
427
+ modules: pkg.modules.map(m => m.name),
428
+ percentage: bundleTotalSize > 0 ? (packageTotalSize / bundleTotalSize) * 100 : 0
429
+ });
430
+ });
431
+ return packages
432
+ .sort((a, b) => b.totalSize - a.totalSize)
433
+ .slice(0, 20); // Top 20 packages
434
+ }
435
+ detectUnusedModules(stats) {
436
+ const unused = [];
437
+ const importedModules = new Set();
438
+ // Build set of all modules that are imported by others
439
+ stats.modules.forEach(module => {
440
+ const reasons = Array.isArray(module.reasons) ? module.reasons : [];
441
+ reasons.forEach(reason => {
442
+ // Extract module name from reason (format varies by bundler)
443
+ if (typeof reason === 'string' && reason !== 'entry' && reason !== 'cjs require') {
444
+ // Try to extract module path from reason
445
+ const match = reason.match(/([^\s]+\.(js|ts|jsx|tsx))/);
446
+ if (match && match[1]) {
447
+ importedModules.add(match[1]);
448
+ }
449
+ }
450
+ });
451
+ });
452
+ // Find modules that are not entry points and not imported
453
+ stats.modules.forEach(module => {
454
+ const reasons = Array.isArray(module.reasons) ? module.reasons : [];
455
+ const isEntry = reasons.some(r => {
456
+ const reasonStr = typeof r === 'string' ? r : String(r);
457
+ return reasonStr === 'entry' || reasonStr.includes('entry');
458
+ });
459
+ const isImported = importedModules.has(module.name);
460
+ if (!isEntry && !isImported && (module.size || 0) > 0) {
461
+ // Additional check: module might be in a chunk but not actually used
462
+ const inChunk = stats.chunks.some(chunk => {
463
+ const chunkModules = Array.isArray(chunk.modules) ? chunk.modules : [];
464
+ return chunkModules.includes(module.name);
465
+ });
466
+ if (inChunk) {
467
+ unused.push({
468
+ name: module.name || 'unknown',
469
+ size: module.size || 0,
470
+ reason: 'Module in bundle but no clear import path detected'
471
+ });
472
+ }
473
+ }
474
+ });
475
+ return unused
476
+ .sort((a, b) => b.size - a.size)
477
+ .slice(0, 20); // Top 20 potentially unused modules
478
+ }
479
+ analyzeChunks(stats) {
480
+ const chunks = stats.chunks;
481
+ if (chunks.length === 0) {
482
+ return {
483
+ averageChunkSize: 0,
484
+ averageModulesPerChunk: 0,
485
+ largestChunk: { id: 0, name: 'N/A', size: 0, modules: [] },
486
+ smallestChunk: { id: 0, name: 'N/A', size: 0, modules: [] },
487
+ initialChunkSize: 0,
488
+ recommendations: []
489
+ };
490
+ }
491
+ const chunkSizes = chunks.map(c => c.size || 0);
492
+ const totalChunkSize = chunkSizes.reduce((a, b) => a + b, 0);
493
+ const averageChunkSize = chunks.length > 0 ? totalChunkSize / chunks.length : 0;
494
+ const modulesPerChunk = chunks.map(c => (c.modules && Array.isArray(c.modules)) ? c.modules.length : 0);
495
+ const totalModules = modulesPerChunk.reduce((a, b) => a + b, 0);
496
+ const averageModulesPerChunk = chunks.length > 0 ? totalModules / chunks.length : 0;
497
+ const sortedBySize = [...chunks].sort((a, b) => (b.size || 0) - (a.size || 0));
498
+ const largestChunk = sortedBySize[0] || { id: 0, name: 'N/A', size: 0, modules: [] };
499
+ const smallestChunk = sortedBySize[sortedBySize.length - 1] || { id: 0, name: 'N/A', size: 0, modules: [] };
500
+ const initialChunks = chunks.filter(c => {
501
+ // Try to identify initial chunks
502
+ const chunkInfo = stats.chunks.find(ch => ch.id === c.id);
503
+ return chunkInfo && 'initial' in chunkInfo && chunkInfo.initial === true;
504
+ });
505
+ const initialChunkSize = initialChunks.reduce((sum, c) => sum + (c.size || 0), 0);
506
+ const recommendations = [];
507
+ // Large chunks recommendation
508
+ if (averageChunkSize > 500000) {
509
+ recommendations.push(`Average chunk size is ${(averageChunkSize / 1024).toFixed(2)}KB - consider splitting large chunks`);
510
+ }
511
+ // Too many small chunks
512
+ if (chunks.length > 20 && averageChunkSize < 50000) {
513
+ recommendations.push(`Many small chunks (${chunks.length}) - consider combining related chunks`);
514
+ }
515
+ // Initial chunk too large
516
+ if (initialChunkSize > 500000) {
517
+ recommendations.push(`Initial chunk is ${(initialChunkSize / 1024).toFixed(2)}KB - implement code-splitting for better load times`);
518
+ }
519
+ // Chunk size imbalance
520
+ if (smallestChunk.size > 0 && largestChunk.size > smallestChunk.size * 10) {
521
+ recommendations.push(`Chunk size imbalance detected - largest chunk is ${(largestChunk.size / smallestChunk.size).toFixed(1)}x larger than smallest`);
522
+ }
523
+ return {
524
+ averageChunkSize,
525
+ averageModulesPerChunk,
526
+ largestChunk,
527
+ smallestChunk,
528
+ initialChunkSize,
529
+ recommendations
530
+ };
531
+ }
164
532
  calculateMetrics(stats) {
165
533
  const sizes = stats.modules.map(m => m.size);
166
534
  const averageSize = sizes.length > 0 ? sizes.reduce((a, b) => a + b, 0) / sizes.length : 0;
535
+ const gzipSize = stats.gzipSize || 0;
536
+ // Brotli is typically 15-20% smaller than gzip, estimate at 17% reduction
537
+ const brotliSize = gzipSize > 0 ? Math.round(gzipSize * 0.83) : undefined;
167
538
  return {
168
539
  totalSize: stats.size,
169
- totalGzipSize: stats.gzipSize || 0,
540
+ totalGzipSize: gzipSize,
541
+ totalBrotliSize: brotliSize,
170
542
  moduleCount: stats.modules.length,
171
543
  chunkCount: stats.chunks.length,
172
544
  largestModule: stats.modules[0] || {