skilldrop-cli 0.16.36 → 0.16.37

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 (93) hide show
  1. package/README.md +1 -1
  2. package/catalogue.json +3 -1
  3. package/model-routing.json +8 -0
  4. package/package.json +1 -1
  5. package/packs/ai-engineering/skills/judge-audit/SKILL.md +157 -0
  6. package/packs/ai-engineering/skills/judge-audit/evals/eval_queries.json +11 -0
  7. package/packs/ai-engineering/skills/judge-audit/evals/evals.json +136 -0
  8. package/packs/ai-engineering/skills/judge-audit/evals/files/1/judgements.csv +321 -0
  9. package/packs/ai-engineering/skills/judge-audit/evals/files/1/notes.md +28 -0
  10. package/packs/ai-engineering/skills/judge-audit/evals/files/2/pairwise.csv +101 -0
  11. package/packs/ai-engineering/skills/judge-audit/evals/files/2/run-notes.md +16 -0
  12. package/packs/ai-engineering/skills/judge-audit/evals/files/4/grades.csv +97 -0
  13. package/packs/ai-engineering/skills/judge-audit/evals/files/5/pairwise_judgements.jsonl +120 -0
  14. package/packs/ai-engineering/skills/judge-audit/evals/files/6/rag-judge/README.md +36 -0
  15. package/packs/ai-engineering/skills/judge-audit/evals/files/6/rag-judge/judgments.csv +301 -0
  16. package/packs/ai-engineering/skills/judge-audit/evals/files/7/moderation/gold_labels.csv +101 -0
  17. package/packs/ai-engineering/skills/judge-audit/evals/files/7/moderation/run_1.jsonl +100 -0
  18. package/packs/ai-engineering/skills/judge-audit/evals/files/7/moderation/run_2.jsonl +100 -0
  19. package/packs/ai-engineering/skills/judge-audit/evals/files/8/patches.csv +241 -0
  20. package/packs/ai-engineering/skills/judge-audit/evals/files/9/adequacy_ratings.csv +151 -0
  21. package/packs/ai-engineering/skills/judge-audit/examples/summary-panel.md +70 -0
  22. package/packs/ai-engineering/skills/judge-audit/manifest.json +44 -0
  23. package/packs/ai-engineering/skills/judge-audit/reference.md +176 -0
  24. package/packs/ai-engineering/skills/judge-audit/scripts/judge_stats.py +1735 -0
  25. package/packs/frontend/pack.json +9 -3
  26. package/packs/frontend/skills/bundle-size-review/SKILL.md +114 -0
  27. package/packs/frontend/skills/bundle-size-review/evals/eval_queries.json +12 -0
  28. package/packs/frontend/skills/bundle-size-review/evals/evals.json +108 -0
  29. package/packs/frontend/skills/bundle-size-review/evals/files/1/package.json +28 -0
  30. package/packs/frontend/skills/bundle-size-review/evals/files/1/src/utils/format.js +26 -0
  31. package/packs/frontend/skills/bundle-size-review/evals/files/1/stats.json +1334 -0
  32. package/packs/frontend/skills/bundle-size-review/evals/files/2/PR.md +21 -0
  33. package/packs/frontend/skills/bundle-size-review/evals/files/2/meta-after.json +440 -0
  34. package/packs/frontend/skills/bundle-size-review/evals/files/2/meta-before.json +360 -0
  35. package/packs/frontend/skills/bundle-size-review/evals/files/3/package.json +29 -0
  36. package/packs/frontend/skills/bundle-size-review/evals/files/3/stats.json +1583 -0
  37. package/packs/frontend/skills/bundle-size-review/evals/files/4/src/components/CommentBox.jsx +24 -0
  38. package/packs/frontend/skills/bundle-size-review/evals/files/4/stats/after.json +299 -0
  39. package/packs/frontend/skills/bundle-size-review/evals/files/4/stats/before.json +275 -0
  40. package/packs/frontend/skills/bundle-size-review/evals/files/5/.browserslistrc +3 -0
  41. package/packs/frontend/skills/bundle-size-review/evals/files/5/build/meta.json +371 -0
  42. package/packs/frontend/skills/bundle-size-review/evals/files/5/build.mjs +18 -0
  43. package/packs/frontend/skills/bundle-size-review/evals/files/5/package-lock.json +39 -0
  44. package/packs/frontend/skills/bundle-size-review/evals/files/5/package.json +22 -0
  45. package/packs/frontend/skills/bundle-size-review/evals/files/5/src/components/Icon.jsx +8 -0
  46. package/packs/frontend/skills/bundle-size-review/evals/files/5/src/polyfills.js +7 -0
  47. package/packs/frontend/skills/bundle-size-review/evals/files/5/src/utils/groupRows.js +4 -0
  48. package/packs/frontend/skills/bundle-size-review/evals/files/6/.browserslistrc +3 -0
  49. package/packs/frontend/skills/bundle-size-review/evals/files/6/package.json +25 -0
  50. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/App.tsx +8 -0
  51. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/chartLocales.ts +16 -0
  52. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/index.ts +8 -0
  53. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/messages/de.ts +5 -0
  54. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/messages/en.ts +5 -0
  55. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/useLocale.ts +14 -0
  56. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/main.tsx +10 -0
  57. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/pages/Home.tsx +14 -0
  58. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/pages/Reports.tsx +31 -0
  59. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/routes.tsx +17 -0
  60. package/packs/frontend/skills/bundle-size-review/evals/files/6/stats.json +1869 -0
  61. package/packs/frontend/skills/bundle-size-review/evals/files/6/vite.config.ts +13 -0
  62. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/package.json +26 -0
  63. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/src/App.tsx +27 -0
  64. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/src/pages/Analytics.tsx +10 -0
  65. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/src/pages/Users.tsx +13 -0
  66. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/stats.json +1421 -0
  67. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/webpack.config.js +25 -0
  68. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/package.json +27 -0
  69. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/App.tsx +18 -0
  70. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/index.tsx +4 -0
  71. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/pages/Home.tsx +10 -0
  72. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/pages/Product.tsx +13 -0
  73. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/stats.json +1496 -0
  74. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/webpack.config.js +26 -0
  75. package/packs/frontend/skills/bundle-size-review/evals/files/7/package.json +9 -0
  76. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/Button.css +3 -0
  77. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/Button.js +14 -0
  78. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/Chart.js +21 -0
  79. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/index.js +22 -0
  80. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/package.json +20 -0
  81. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/Button.css +3 -0
  82. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/Button.tsx +9 -0
  83. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/Chart.tsx +14 -0
  84. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/index.ts +6 -0
  85. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/tsconfig.json +14 -0
  86. package/packs/frontend/skills/bundle-size-review/examples/inputs/stats-after.json +1358 -0
  87. package/packs/frontend/skills/bundle-size-review/examples/inputs/stats-before.json +1334 -0
  88. package/packs/frontend/skills/bundle-size-review/examples/larkspur-sparkline.md +52 -0
  89. package/packs/frontend/skills/bundle-size-review/manifest.json +52 -0
  90. package/packs/frontend/skills/bundle-size-review/reference.md +147 -0
  91. package/packs/frontend/skills/bundle-size-review/scripts/bundle_stats.py +1633 -0
  92. package/packs/frontend/skills/web-performance-review/SKILL.md +3 -3
  93. package/packs/frontend/skills/web-performance-review/manifest.json +2 -1
@@ -0,0 +1,1633 @@
1
+ #!/usr/bin/env python3
2
+ """Size a JavaScript bundle from the bundler's stats file and say what pulls each module in. Stdlib only.
3
+
4
+ Reads one of these, detected from its keys:
5
+ - webpack stats JSON (`webpack --json > stats.json`): assets, chunks (initial / entry),
6
+ modules with name, size, chunks and reasons, entrypoints. Multi-compiler output
7
+ (`children`) uses the first child.
8
+ - esbuild metafile (`--metafile=meta.json`): inputs with imports; outputs with bytes,
9
+ entryPoint, imports (import-statement / dynamic-import) and inputs[].bytesInOutput.
10
+ - rollup-plugin-visualizer raw data (`template: "raw-data"`, also what Vite uses):
11
+ tree, nodeParts (renderedLength, gzipLength, brotliLength) and nodeMetas
12
+ (id, moduleParts, imported, importedBy, isEntry).
13
+
14
+ It prints, per entry, the initial JavaScript (what blocks first render) and the lazy chunks;
15
+ the largest modules; packages by size; packages installed at more than one path, with what
16
+ pulls each copy in; mechanical flags (whole-library CommonJS imports, locale bundles,
17
+ polyfills, modules in several chunks); and, with --why, the import chain from an entry to a
18
+ module or package. With --compare it diffs two stats files by entry, chunk, package and module.
19
+
20
+ It also reads the project around the stats file (the first of the stats folder, its parent or
21
+ grandparent that holds package.json, .browserslistrc or src/; or --root): package.json
22
+ (dependency ranges, browserslist), .browserslistrc, the lockfile (installed versions and the
23
+ range each dependent declares), bundler and TypeScript configs (target, existing budgets), and
24
+ the source files on disk that import the bundled packages. It prints what it found and what is
25
+ absent, and checks each bundled core-js polyfill against the browser targets.
26
+
27
+ Every size is in bytes (kB = 1000 bytes), exactly as the stats give them. Compressed sizes are
28
+ printed only when measured: "built-file" gzips the emitted files found under --dist (level 9);
29
+ "visualizer" sums the plugin's per-module gzip/brotli lengths. Nothing is estimated.
30
+
31
+ Usage:
32
+ python3 bundle_stats.py stats.json [--why lodash] [--budget main=170kB] [--dist dist/] [--root .]
33
+ python3 bundle_stats.py --compare before.json after.json [--budget main=200000] [--json]
34
+ cat stats.json | python3 bundle_stats.py - --json -o report.json
35
+
36
+ A budget is ENTRY=SIZE on the entry's initial JavaScript in the stats' own size (bytes, or
37
+ with a kB / KiB suffix), or ENTRY=gzip:SIZE when compressed sizes are measured.
38
+
39
+ Exit codes: 0 report written; 1 a --budget was exceeded; 2 bad input.
40
+ """
41
+ import argparse
42
+ import gzip
43
+ import json
44
+ import os
45
+ import re
46
+ import sys
47
+ from collections import defaultdict, deque
48
+
49
+ KIB = 1024.0
50
+ KB = 1000.0
51
+ JS_EXT = (".js", ".mjs", ".cjs")
52
+ CSS_EXT = (".css",)
53
+
54
+ POLYFILL_PACKAGES = {
55
+ "core-js", "core-js-pure", "@babel/polyfill", "regenerator-runtime", "whatwg-fetch",
56
+ "promise-polyfill", "es6-promise", "intersection-observer", "resize-observer-polyfill",
57
+ "url-polyfill", "url-search-params-polyfill", "abortcontroller-polyfill",
58
+ "smoothscroll-polyfill", "formdata-polyfill", "element-closest", "custom-event-polyfill",
59
+ "@webcomponents/webcomponentsjs", "web-streams-polyfill", "unfetch", "isomorphic-fetch",
60
+ }
61
+
62
+ HASH_RE = re.compile(r"[.-]([A-Za-z0-9_-]{8,}|[0-9a-f]{6,})(?=\.(?:m?js|cjs|css)$)")
63
+ PNPM_RE = re.compile(r"\.pnpm/(@[^/+]+\+[^/@]+|[^/@]+)@([0-9][^/_(]*)")
64
+ YARN_RE = re.compile(r"([^/]+)-npm-([0-9][^/]*?)-[0-9a-f]{6,}-[0-9a-f]{6,}\.zip")
65
+ CONCAT_RE = re.compile(r" \+ \d+ modules?$")
66
+
67
+
68
+ class InputError(Exception):
69
+ pass
70
+
71
+
72
+ # ---------------------------------------------------------------- helpers
73
+
74
+ def kib(n):
75
+ """Exact bytes, one unit system throughout: 163,000 B."""
76
+ if n is None:
77
+ return "n/a"
78
+ return "{:,} B".format(int(round(n)))
79
+
80
+
81
+ def bkb(n):
82
+ """Exact bytes with the kB (1000 bytes) figure: 163,000 B (163.0 kB)."""
83
+ if n is None:
84
+ return "not measured"
85
+ return "{:,} B ({:.1f} kB)".format(int(round(n)), n / KB)
86
+
87
+
88
+ def clean_path(p):
89
+ if p is None:
90
+ return ""
91
+ p = str(p).replace("\\", "/")
92
+ p = p.lstrip("\0")
93
+ p = CONCAT_RE.sub("", p)
94
+ if "!" in p:
95
+ p = p.split("!")[-1]
96
+ p = p.split("?")[0]
97
+ while p.startswith("./"):
98
+ p = p[2:]
99
+ return p.lstrip("/")
100
+
101
+
102
+ def strip_hash(name):
103
+ def repl(m):
104
+ h = m.group(1)
105
+ looks_hash = (any(c.isdigit() for c in h) or (h.lower() != h and h.upper() != h)
106
+ or re.fullmatch(r"[0-9a-f]{6,}", h) is not None)
107
+ return "" if looks_hash else m.group(0)
108
+ return HASH_RE.sub(repl, name)
109
+
110
+
111
+ def package_of(path):
112
+ """(name, install_path, version or None) for a node_modules path, else None."""
113
+ idx = path.rfind("node_modules/")
114
+ if idx < 0:
115
+ return None
116
+ rest = path[idx + len("node_modules/"):]
117
+ parts = rest.split("/")
118
+ if not parts or not parts[0] or parts[0].startswith("."):
119
+ return None
120
+ if parts[0].startswith("@") and len(parts) > 1:
121
+ name = parts[0] + "/" + parts[1]
122
+ else:
123
+ name = parts[0]
124
+ install = path[:idx] + "node_modules/" + name
125
+ prefix = path[:idx]
126
+ version = None
127
+ found = PNPM_RE.findall(prefix)
128
+ if found:
129
+ seg_name, seg_ver = found[-1]
130
+ if seg_name.replace("+", "/") == name:
131
+ version = seg_ver
132
+ if version is None:
133
+ found = YARN_RE.findall(prefix)
134
+ if found:
135
+ seg_name, seg_ver = found[-1]
136
+ if seg_name == name.replace("/", "-").lstrip("@") or seg_name == name.replace("/", "-"):
137
+ version = seg_ver
138
+ return name, install, version
139
+
140
+
141
+ def owner_label(path):
142
+ pkg = package_of(path)
143
+ return pkg[0] if pkg else path
144
+
145
+
146
+ def is_js(f):
147
+ return f.lower().endswith(JS_EXT)
148
+
149
+
150
+ def is_css(f):
151
+ return f.lower().endswith(CSS_EXT)
152
+
153
+
154
+ def stem(name):
155
+ base = os.path.basename(strip_hash(name))
156
+ return base.rsplit(".", 1)[0] if "." in base else base
157
+
158
+
159
+ def new_bundle(fmt, tool):
160
+ return {"format": fmt, "tool": tool, "notes": [], "modules": {}, "edges": [],
161
+ "chunks": {}, "entries": {}, "entry_modules": set(), "cjs_paths": set(),
162
+ "raw_basis": "", "module_basis": ""}
163
+
164
+
165
+ def add_module(b, key, size=0, chunk=None, source=None):
166
+ m = b["modules"].get(key)
167
+ if m is None:
168
+ m = {"path": key, "size": 0, "chunks": set(), "source": None, "cjs": False,
169
+ "chunk_sizes": defaultdict(int)}
170
+ b["modules"][key] = m
171
+ m["size"] += size
172
+ if chunk is not None:
173
+ m["chunks"].add(chunk)
174
+ m["chunk_sizes"][chunk] += size
175
+ if source is not None and m["source"] is None:
176
+ m["source"] = source
177
+ return m
178
+
179
+
180
+ def new_chunk(b, key, name, files, initial, entry):
181
+ ch = {"key": key, "name": name, "files": list(files), "raw": 0, "css_raw": 0,
182
+ "initial": initial, "entry": entry, "loaded_from": [], "vis_gzip": None,
183
+ "vis_brotli": None, "raw_from_modules": False}
184
+ b["chunks"][key] = ch
185
+ return ch
186
+
187
+
188
+ def unique_key(existing, key):
189
+ if key not in existing:
190
+ return key
191
+ i = 2
192
+ while "%s#%d" % (key, i) in existing:
193
+ i += 1
194
+ return "%s#%d" % (key, i)
195
+
196
+
197
+ # ---------------------------------------------------------------- webpack
198
+
199
+ def load_webpack(data):
200
+ if not data.get("modules") and not data.get("chunks") and isinstance(data.get("children"), list) and data["children"]:
201
+ child = data["children"][0]
202
+ b = load_webpack(child)
203
+ b["notes"].append("multi-compiler stats: only the first child (%s) is read"
204
+ % (child.get("name") or "unnamed"))
205
+ return b
206
+ tool = "webpack %s" % data.get("version", "").strip() if data.get("version") else "webpack"
207
+ b = new_bundle("webpack", tool.strip())
208
+ assets = {}
209
+ for a in data.get("assets") or []:
210
+ if isinstance(a, dict) and a.get("name"):
211
+ assets[a["name"]] = a.get("size") or 0
212
+ id_to_key = {}
213
+ for c in data.get("chunks") or []:
214
+ names = c.get("names") or []
215
+ files = [f for f in (c.get("files") or []) if not f.endswith(".map") and ".hot-update." not in f]
216
+ if names:
217
+ key = names[0]
218
+ elif files:
219
+ key = strip_hash(files[0])
220
+ else:
221
+ key = "chunk %s" % c.get("id")
222
+ key = unique_key(b["chunks"], key)
223
+ id_to_key[c.get("id")] = key
224
+ ch = new_chunk(b, key, names[0] if names else key, files, bool(c.get("initial")), bool(c.get("entry")))
225
+ js_files = [f for f in files if is_js(f)]
226
+ if js_files and all(f in assets for f in js_files):
227
+ ch["raw"] = sum(assets[f] for f in js_files)
228
+ else:
229
+ ch["raw"] = c.get("size") or 0
230
+ ch["raw_from_modules"] = True
231
+ ch["css_raw"] = sum(assets.get(f, 0) for f in files if is_css(f))
232
+ for o in c.get("origins") or []:
233
+ mn = o.get("moduleName")
234
+ if mn and not ch["initial"]:
235
+ ch["loaded_from"].append(clean_path(mn))
236
+ raw_modules = list(data.get("modules") or [])
237
+ if not raw_modules:
238
+ for c in data.get("chunks") or []:
239
+ for m in c.get("modules") or []:
240
+ m = dict(m)
241
+ m.setdefault("chunks", [c.get("id")])
242
+ raw_modules.append(m)
243
+
244
+ def walk(m, inherited_chunks):
245
+ name = m.get("name") or m.get("identifier") or ""
246
+ mtype = m.get("moduleType") or ""
247
+ if mtype == "runtime" or name.startswith("webpack/runtime") or name.startswith("external "):
248
+ return
249
+ chunks = m.get("chunks")
250
+ if chunks is None:
251
+ chunks = inherited_chunks
252
+ nested = m.get("modules") or []
253
+ if nested:
254
+ for n in nested:
255
+ walk(n, chunks)
256
+ return
257
+ key = clean_path(name)
258
+ if not key:
259
+ return
260
+ size = m.get("size") or 0
261
+ ckeys = [id_to_key[c] for c in chunks if c in id_to_key] or [None]
262
+ for ck in ckeys:
263
+ add_module(b, key, size, ck, m.get("source") if isinstance(m.get("source"), str) else None)
264
+ mod = b["modules"][key]
265
+ mod["size"] = size # a module in several chunks is one module; chunk_sizes holds the copies
266
+ bail = " ".join(m.get("optimizationBailout") or [])
267
+ if "not an ECMAScript module" in bail or mtype == "javascript/dynamic" or (
268
+ "providedExports" in m and m.get("providedExports") is None and mtype != "javascript/esm"
269
+ and key.endswith(JS_EXT)):
270
+ mod["cjs"] = True
271
+ for r in m.get("reasons") or []:
272
+ rtype = (r.get("type") or "").lower()
273
+ src = r.get("moduleName") or r.get("resolvedModule")
274
+ if "entry" in rtype or not src:
275
+ if "entry" in rtype:
276
+ b["entry_modules"].add(key)
277
+ continue
278
+ kind = "dynamic" if "import()" in rtype or "dynamic" in rtype else "static"
279
+ if "cjs" in rtype or "require" in rtype:
280
+ kind = "static"
281
+ b["edges"].append((clean_path(src), key, kind, r.get("userRequest")))
282
+ if m.get("depth") == 0:
283
+ b["entry_modules"].add(key)
284
+
285
+ for m in raw_modules:
286
+ walk(m, [])
287
+
288
+ eps = data.get("entrypoints") or {}
289
+ if eps:
290
+ for name, ep in eps.items():
291
+ ckeys = [id_to_key[c] for c in (ep.get("chunks") or []) if c in id_to_key]
292
+ b["entries"][name] = {"chunks": ckeys}
293
+ else:
294
+ for ch in b["chunks"].values():
295
+ if ch["entry"]:
296
+ b["entries"][ch["name"]] = {"chunks": [ch["key"]]}
297
+ from_mods = [ch for ch in b["chunks"].values() if ch["raw_from_modules"]]
298
+ if from_mods and len(from_mods) == len(b["chunks"]):
299
+ b["raw_basis"] = "webpack chunk size = sum of module sizes, before minification (the stats have no assets)"
300
+ elif from_mods:
301
+ b["notes"].append("some chunks have no emitted-asset size in the stats; their size is the sum of module sizes before minification")
302
+ b["raw_basis"] = "emitted asset bytes (after minification) where the stats list the asset"
303
+ else:
304
+ b["raw_basis"] = "emitted asset bytes (after minification, before compression)"
305
+ b["module_basis"] = "webpack module size, before minification"
306
+ return b
307
+
308
+
309
+ # ---------------------------------------------------------------- esbuild
310
+
311
+ def load_esbuild(data):
312
+ b = new_bundle("esbuild", "esbuild metafile")
313
+ inputs = data.get("inputs") or {}
314
+ outputs = {k: v for k, v in (data.get("outputs") or {}).items() if not k.endswith(".map")}
315
+ dynamic_targets = set()
316
+ for ipath, inp in inputs.items():
317
+ src = clean_path(ipath)
318
+ if (inp.get("format") or "") == "cjs":
319
+ b["cjs_paths"].add(src)
320
+ for imp in inp.get("imports") or []:
321
+ if imp.get("external") or not imp.get("path"):
322
+ continue
323
+ kind = imp.get("kind") or ""
324
+ if kind in ("import-rule", "url-token", "composes-from"):
325
+ continue
326
+ k = "dynamic" if kind == "dynamic-import" else "static"
327
+ dst = clean_path(imp["path"])
328
+ if k == "dynamic":
329
+ dynamic_targets.add(dst)
330
+ b["edges"].append((src, dst, k, imp.get("original")))
331
+ out_key = {}
332
+ for oname, out in outputs.items():
333
+ if is_css(oname):
334
+ continue # a CSS output is counted on the JS output that names it as cssBundle
335
+ ep = clean_path(out.get("entryPoint")) if out.get("entryPoint") else None
336
+ if ep:
337
+ key = ep
338
+ else:
339
+ biggest = max((out.get("inputs") or {}).items(), key=lambda kv: kv[1].get("bytesInOutput", 0), default=None)
340
+ key = "shared chunk: " + (owner_label(clean_path(biggest[0])) if biggest else strip_hash(oname))
341
+ key = unique_key(b["chunks"], key)
342
+ out_key[oname] = key
343
+ is_lazy = bool(ep) and ep in dynamic_targets
344
+ ch = new_chunk(b, key, os.path.basename(oname), [oname], False, bool(ep) and not is_lazy)
345
+ ch["raw"] = out.get("bytes") or 0
346
+ ch["dyn_entry"] = is_lazy
347
+ for ipath, info in (out.get("inputs") or {}).items():
348
+ add_module(b, clean_path(ipath), info.get("bytesInOutput") or 0, key)
349
+ for oname, out in outputs.items():
350
+ key = out_key.get(oname)
351
+ if key and out.get("cssBundle") and out["cssBundle"] in outputs:
352
+ b["chunks"][key]["css_raw"] = outputs[out["cssBundle"]].get("bytes") or 0
353
+ # initial closure per entry over static output imports
354
+ for oname, out in outputs.items():
355
+ key = out_key.get(oname)
356
+ if not key or not b["chunks"][key]["entry"] or is_css(oname):
357
+ continue
358
+ seen = [key]
359
+ stack = [oname]
360
+ while stack:
361
+ cur = stack.pop()
362
+ for imp in outputs.get(cur, {}).get("imports") or []:
363
+ if imp.get("external") or imp.get("kind") == "dynamic-import":
364
+ continue
365
+ k2 = out_key.get(imp.get("path"))
366
+ if k2 and k2 not in seen:
367
+ seen.append(k2)
368
+ stack.append(imp["path"])
369
+ ep = clean_path(out["entryPoint"])
370
+ b["entries"][ep] = {"chunks": seen}
371
+ b["entry_modules"].add(ep)
372
+ initial = set(k for e in b["entries"].values() for k in e["chunks"])
373
+ for k, ch in b["chunks"].items():
374
+ ch["initial"] = k in initial
375
+ for src, dst, kind, _ in b["edges"]:
376
+ if kind == "dynamic":
377
+ for ch in b["chunks"].values():
378
+ if ch.get("dyn_entry") and ch["key"] == dst:
379
+ ch["loaded_from"].append(src)
380
+ for p in b["cjs_paths"]:
381
+ if p in b["modules"]:
382
+ b["modules"][p]["cjs"] = True
383
+ b["raw_basis"] = "output file bytes (minified if --minify was on; before compression; .map files left out)"
384
+ b["module_basis"] = "esbuild bytesInOutput (bytes each input contributes to the output)"
385
+ return b
386
+
387
+
388
+ # ---------------------------------------------------------------- rollup-plugin-visualizer
389
+
390
+ def load_rollup(data):
391
+ if data.get("version") not in (None, 2, 3):
392
+ raise InputError("rollup-plugin-visualizer data version %r is not supported (expected 2)" % data.get("version"))
393
+ b = new_bundle("rollup-visualizer", "rollup-plugin-visualizer raw data")
394
+ env = data.get("env") or {}
395
+ if env.get("rollup"):
396
+ b["tool"] = "rollup %s (visualizer raw data)" % env["rollup"]
397
+ opts = data.get("options") or {}
398
+ parts = data.get("nodeParts") or {}
399
+ metas = data.get("nodeMetas") or {}
400
+ uid_to_key = {}
401
+ file_key = {}
402
+ files = []
403
+ for c in (data.get("tree") or {}).get("children") or []:
404
+ if c.get("name"):
405
+ files.append(c["name"])
406
+ for meta in metas.values():
407
+ for f in (meta.get("moduleParts") or {}):
408
+ if f not in files:
409
+ files.append(f)
410
+ for f in files:
411
+ key = unique_key(b["chunks"], strip_hash(f))
412
+ file_key[f] = key
413
+ new_chunk(b, key, f, [f], False, False)
414
+ gz_on = bool(opts.get("gzip"))
415
+ br_on = bool(opts.get("brotli"))
416
+ for uid, meta in metas.items():
417
+ raw_id = str(meta.get("id") or "")
418
+ key = clean_path(raw_id)
419
+ uid_to_key[uid] = key
420
+ if "?commonjs" in raw_id:
421
+ b["cjs_paths"].add(key)
422
+ if meta.get("isExternal"):
423
+ continue
424
+ if meta.get("isEntry"):
425
+ b["entry_modules"].add(key)
426
+ for f, puid in (meta.get("moduleParts") or {}).items():
427
+ part = parts.get(puid) or {}
428
+ ck = file_key.get(f)
429
+ mod = add_module(b, key, part.get("renderedLength") or 0, ck)
430
+ if gz_on:
431
+ mod["gzip"] = (mod.get("gzip") or 0) + (part.get("gzipLength") or 0)
432
+ ch = b["chunks"][ck]
433
+ ch["raw"] += part.get("renderedLength") or 0
434
+ if gz_on:
435
+ ch["vis_gzip"] = (ch["vis_gzip"] or 0) + (part.get("gzipLength") or 0)
436
+ if br_on:
437
+ ch["vis_brotli"] = (ch["vis_brotli"] or 0) + (part.get("brotliLength") or 0)
438
+ for uid, meta in metas.items():
439
+ src = uid_to_key[uid]
440
+ for imp in meta.get("imported") or []:
441
+ dst = uid_to_key.get(imp.get("uid"))
442
+ if dst is None or dst == src:
443
+ continue
444
+ if (metas.get(imp.get("uid")) or {}).get("isExternal"):
445
+ continue
446
+ b["edges"].append((src, dst, "dynamic" if imp.get("dynamic") else "static", None))
447
+ static_fwd = defaultdict(list)
448
+ for s, d, k, _ in b["edges"]:
449
+ if k == "static":
450
+ static_fwd[s].append(d)
451
+ dyn_targets = set(d for s, d, k, _ in b["edges"] if k == "dynamic")
452
+ for em in sorted(b["entry_modules"]):
453
+ if em in dyn_targets or em not in b["modules"]:
454
+ continue
455
+ seen = {em}
456
+ q = deque([em])
457
+ while q:
458
+ cur = q.popleft()
459
+ for d in static_fwd.get(cur, []):
460
+ if d not in seen:
461
+ seen.add(d)
462
+ q.append(d)
463
+ ckeys = []
464
+ for mk in seen:
465
+ for ck in (b["modules"].get(mk) or {}).get("chunks", ()):
466
+ if ck and ck not in ckeys:
467
+ ckeys.append(ck)
468
+ own = sorted(b["modules"][em]["chunks"])
469
+ ckeys = own + sorted(c for c in ckeys if c not in own)
470
+ b["entries"][em] = {"chunks": ckeys}
471
+ for ck in own:
472
+ b["chunks"][ck]["entry"] = True
473
+ b["entry_modules"] -= dyn_targets
474
+ initial = set(k for e in b["entries"].values() for k in e["chunks"])
475
+ for k, ch in b["chunks"].items():
476
+ ch["initial"] = k in initial
477
+ if not ch["initial"]:
478
+ members = set(mk for mk, m in b["modules"].items() if k in m["chunks"])
479
+ for s, d, kind, _ in b["edges"]:
480
+ if kind == "dynamic" and d in members and s not in members and s not in ch["loaded_from"]:
481
+ ch["loaded_from"].append(s)
482
+ for p in b["cjs_paths"]:
483
+ if p in b["modules"]:
484
+ b["modules"][p]["cjs"] = True
485
+ b["raw_basis"] = "rendered length (after tree-shaking; before minification unless the visualizer runs after the minifier)"
486
+ b["module_basis"] = "rendered length per module"
487
+ if not gz_on:
488
+ b["notes"].append("the visualizer was run without gzipSize, so no compressed sizes are available")
489
+ return b
490
+
491
+
492
+ # ---------------------------------------------------------------- load
493
+
494
+ def load(path):
495
+ try:
496
+ if path == "-":
497
+ text = sys.stdin.read()
498
+ else:
499
+ with open(path, "r", encoding="utf-8-sig") as fh:
500
+ text = fh.read()
501
+ except OSError as e:
502
+ raise InputError("cannot read %s: %s" % (path, e))
503
+ if not text.strip():
504
+ raise InputError("%s is empty" % path)
505
+ try:
506
+ data = json.loads(text)
507
+ except ValueError as e:
508
+ raise InputError("%s is not JSON (%s). webpack prints build logs before the JSON unless run with --json > stats.json" % (path, e))
509
+ if not isinstance(data, dict):
510
+ raise InputError("%s is JSON but not an object" % path)
511
+ if "lighthouseVersion" in data:
512
+ raise InputError("%s is a Lighthouse report, not bundler stats" % path)
513
+ if "nodeMetas" in data and "nodeParts" in data:
514
+ b = load_rollup(data)
515
+ elif isinstance(data.get("inputs"), dict) and isinstance(data.get("outputs"), dict):
516
+ b = load_esbuild(data)
517
+ elif any(k in data for k in ("modules", "chunks", "assets", "entrypoints")) or isinstance(data.get("children"), list):
518
+ b = load_webpack(data)
519
+ else:
520
+ raise InputError("%s is not a webpack stats file, an esbuild metafile or rollup-plugin-visualizer raw data" % path)
521
+ if not b["modules"] and not b["chunks"]:
522
+ raise InputError("%s has no modules or chunks to size" % path)
523
+ rev = defaultdict(list)
524
+ for s, d, k, r in b["edges"]:
525
+ if s != d:
526
+ rev[d].append((s, k, r))
527
+ b["rev"] = rev
528
+ for ch in b["chunks"].values():
529
+ if ch["initial"] or ch["loaded_from"]:
530
+ continue
531
+ members = set(mk for mk, m in b["modules"].items() if ch["key"] in m["chunks"])
532
+ for s, d, k, _ in b["edges"]:
533
+ if k == "dynamic" and d in members and s not in members and s not in ch["loaded_from"]:
534
+ ch["loaded_from"].append(s)
535
+ if not b["entry_modules"]:
536
+ b["entry_modules"] = set(mk for mk in b["modules"] if mk not in rev)
537
+ b["notes"].append("no entry modules are marked; chains start at modules nothing imports")
538
+ return b
539
+
540
+
541
+ # ---------------------------------------------------------------- analysis
542
+
543
+ def compress_chunk(b, ch, dist):
544
+ js_files = [f for f in ch["files"] if is_js(f)]
545
+ if dist and js_files:
546
+ total = 0
547
+ ok = True
548
+ for f in js_files:
549
+ cand = [os.path.join(dist, f), os.path.join(dist, os.path.basename(f))]
550
+ hit = next((c for c in cand if os.path.isfile(c)), None)
551
+ if not hit:
552
+ ok = False
553
+ break
554
+ with open(hit, "rb") as fh:
555
+ total += len(gzip.compress(fh.read(), compresslevel=9))
556
+ if ok:
557
+ return total, "built-file", None, None
558
+ if ch["vis_gzip"] is not None:
559
+ br = ch["vis_brotli"]
560
+ return ch["vis_gzip"], "visualizer", br, ("visualizer" if br is not None else None)
561
+ return None, None, None, None
562
+
563
+
564
+ def sum_known(vals):
565
+ vals = list(vals)
566
+ return sum(vals) if vals and all(v is not None for v in vals) else None
567
+
568
+
569
+ def basis_of(bases):
570
+ s = sorted(set(x for x in bases if x))
571
+ if not s or None in bases:
572
+ return None
573
+ return s[0] if len(s) == 1 else "mixed: " + ", ".join(s)
574
+
575
+
576
+ def find_chain(b, target, allow_dynamic):
577
+ entries = b["entry_modules"]
578
+ if target in entries:
579
+ return [{"module": target}]
580
+ prev = {target: None}
581
+ q = deque([target])
582
+ while q:
583
+ cur = q.popleft()
584
+ for src, kind, req in b["rev"].get(cur, []):
585
+ if src in prev or (kind == "dynamic" and not allow_dynamic):
586
+ continue
587
+ prev[src] = (cur, kind, req)
588
+ if src in entries:
589
+ path = [{"module": src}]
590
+ node = src
591
+ while prev[node] is not None:
592
+ nxt, k, r = prev[node]
593
+ step = {"module": nxt, "via": k}
594
+ if r:
595
+ step["request"] = r
596
+ path.append(step)
597
+ node = nxt
598
+ return path
599
+ q.append(src)
600
+ return None
601
+
602
+
603
+ def chain_for(b, target):
604
+ c = find_chain(b, target, False)
605
+ return c if c is not None else find_chain(b, target, True)
606
+
607
+
608
+ def analyze(b, dist=None, top=10, why=None):
609
+ chunks = b["chunks"]
610
+ for ch in chunks.values():
611
+ ch["gzip"], ch["gzip_basis"], ch["brotli"], ch["brotli_basis"] = compress_chunk(b, ch, dist)
612
+ initial_keys = set(k for e in b["entries"].values() for k in e["chunks"])
613
+ mods = b["modules"]
614
+ for m in mods.values():
615
+ m["initial"] = any(c in initial_keys for c in m["chunks"])
616
+ m["pkg"] = package_of(m["path"])
617
+ res = {"format": b["format"], "tool": b["tool"], "raw_basis": b["raw_basis"],
618
+ "module_basis": b["module_basis"], "notes": list(b["notes"]), "entries": [],
619
+ "lazy_chunks": [], "largest_modules": [], "packages": [], "duplicates": [],
620
+ "in_several_chunks": [], "flags": []}
621
+ for name, e in b["entries"].items():
622
+ cl = [chunks[k] for k in e["chunks"] if k in chunks]
623
+ res["entries"].append({
624
+ "name": name,
625
+ "js_raw": sum(c["raw"] for c in cl),
626
+ "gzip": sum_known(c["gzip"] for c in cl),
627
+ "gzip_basis": basis_of([c["gzip_basis"] for c in cl]) if cl else None,
628
+ "brotli": sum_known(c["brotli"] for c in cl),
629
+ "brotli_basis": basis_of([c["brotli_basis"] for c in cl]) if cl else None,
630
+ "css_raw": sum(c["css_raw"] for c in cl),
631
+ "chunks": [{"key": c["key"], "files": c["files"], "raw": c["raw"], "gzip": c["gzip"],
632
+ "gzip_basis": c["gzip_basis"]} for c in cl],
633
+ })
634
+ res["entries"].sort(key=lambda e: -e["js_raw"])
635
+
636
+ def chunk_contents(key, n=3):
637
+ agg = defaultdict(int)
638
+ for m in mods.values():
639
+ if key in m["chunks"]:
640
+ agg[owner_label(m["path"])] += m["chunk_sizes"].get(key, m["size"])
641
+ return [{"owner": o, "size": s} for o, s in sorted(agg.items(), key=lambda kv: -kv[1])[:n]]
642
+
643
+ for ch in sorted(chunks.values(), key=lambda c: -c["raw"]):
644
+ if ch["key"] in initial_keys:
645
+ continue
646
+ res["lazy_chunks"].append({"key": ch["key"], "files": ch["files"], "raw": ch["raw"], "gzip": ch["gzip"],
647
+ "gzip_basis": ch["gzip_basis"], "loaded_from": sorted(set(ch["loaded_from"])),
648
+ "mostly": chunk_contents(ch["key"])})
649
+ for m in sorted(mods.values(), key=lambda m: -m["size"])[:top]:
650
+ res["largest_modules"].append({"path": m["path"], "package": m["pkg"][0] if m["pkg"] else None,
651
+ "size": m["size"], "initial": m["initial"], "chunks": sorted(c for c in m["chunks"] if c)})
652
+ pkgs = {}
653
+ for m in mods.values():
654
+ if not m["pkg"]:
655
+ continue
656
+ name, install, version = m["pkg"]
657
+ p = pkgs.setdefault(name, {"name": name, "size": 0, "initial_size": 0, "lazy_size": 0, "modules": 0,
658
+ "installs": {}, "cjs": False, "gzip": 0, "gzip_known": True})
659
+ p["size"] += m["size"]
660
+ if m.get("gzip") is None:
661
+ p["gzip_known"] = False
662
+ else:
663
+ p["gzip"] += m["gzip"]
664
+ p["modules"] += 1
665
+ if m["initial"]:
666
+ p["initial_size"] += m["size"]
667
+ else:
668
+ p["lazy_size"] += m["size"]
669
+ p["cjs"] = p["cjs"] or m["cjs"]
670
+ inst = p["installs"].setdefault(install, {"install": install, "version": version, "size": 0,
671
+ "initial_size": 0, "modules": []})
672
+ inst["size"] += m["size"]
673
+ if m["initial"]:
674
+ inst["initial_size"] += m["size"]
675
+ inst["modules"].append(m["path"])
676
+ plist = sorted(pkgs.values(), key=lambda p: -p["size"])
677
+ for p in plist:
678
+ res["packages"].append({"name": p["name"], "size": p["size"], "initial_size": p["initial_size"],
679
+ "lazy_size": p["lazy_size"], "modules": p["modules"],
680
+ "installs": len(p["installs"]), "commonjs": p["cjs"],
681
+ "gzip": p["gzip"] if p["gzip_known"] else None})
682
+ # duplicates
683
+ for p in plist:
684
+ if len(p["installs"]) < 2:
685
+ continue
686
+ dup = {"package": p["name"], "installs": []}
687
+ for inst in sorted(p["installs"].values(), key=lambda i: -i["size"]):
688
+ members = set(inst["modules"])
689
+ pulled = []
690
+ for mk in inst["modules"]:
691
+ for src, kind, req in b["rev"].get(mk, []):
692
+ if src in members:
693
+ continue
694
+ label = owner_label(src)
695
+ item = "%s (%s)" % (label, req) if req else label
696
+ if item not in pulled:
697
+ pulled.append(item)
698
+ first = None
699
+ for mk in inst["modules"]:
700
+ c = chain_for(b, mk)
701
+ if c and (first is None or len(c) < len(first)):
702
+ first = c
703
+ dup["installs"].append({"install": inst["install"], "version": inst["version"],
704
+ "size": inst["size"], "initial_size": inst["initial_size"],
705
+ "pulled_in_by": pulled, "chain": first})
706
+ res["duplicates"].append(dup)
707
+ # modules copied into several chunks
708
+ several = defaultdict(lambda: {"size": 0, "extra": 0, "chunks": set(), "modules": 0})
709
+ for m in mods.values():
710
+ real = [c for c in m["chunks"] if c]
711
+ if len(real) > 1:
712
+ s = several[owner_label(m["path"])]
713
+ s["size"] += m["size"]
714
+ s["extra"] += m["size"] * (len(real) - 1)
715
+ s["chunks"].update(real)
716
+ s["modules"] += 1
717
+ for owner, s in sorted(several.items(), key=lambda kv: -kv[1]["extra"]):
718
+ if s["extra"] >= 1024:
719
+ res["in_several_chunks"].append({"owner": owner, "size": s["size"], "extra_copies_size": s["extra"],
720
+ "chunks": sorted(s["chunks"]), "modules": s["modules"]})
721
+ # flags, per install path so two copies of one package are judged separately
722
+ for p in plist:
723
+ name = p["name"]
724
+ several_installs = len(p["installs"]) > 1
725
+ for inst in p["installs"].values():
726
+ members = set(inst["modules"])
727
+ label = inst["install"] if several_installs else name
728
+ size = inst["size"]
729
+ initial = inst["initial_size"] > 0
730
+ cjs = any(mods[mk]["cjs"] for mk in members)
731
+ prebuilt = all(".production.min." in mk or mk.endswith(".min.js") for mk in members)
732
+ bare = []
733
+ for mk in inst["modules"]:
734
+ for src, kind, req in b["rev"].get(mk, []):
735
+ if src not in members and req == name:
736
+ lbl = owner_label(src)
737
+ if lbl not in bare:
738
+ bare.append(lbl)
739
+ if bare and cjs and size >= 10 * KIB:
740
+ res["flags"].append({"kind": "whole-library-commonjs", "package": label, "size": size, "initial": initial,
741
+ "detail": "imported by its bare name \"%s\" from %s; this copy is CommonJS, so nothing in it is tree-shaken" % (name, ", ".join(bare))})
742
+ elif bare and len(members) >= 20:
743
+ res["flags"].append({"kind": "barrel-import", "package": label, "size": size, "initial": initial,
744
+ "detail": "%d modules included through the bare import \"%s\" from %s; check whether it is shaken" % (len(members), name, ", ".join(bare))})
745
+ elif cjs and inst["initial_size"] >= 10 * KIB and not prebuilt:
746
+ res["flags"].append({"kind": "commonjs-in-initial", "package": label, "size": inst["initial_size"], "initial": True,
747
+ "detail": "CommonJS, so it cannot be tree-shaken"})
748
+ locale = [mk for mk in inst["modules"] if re.search(r"/(locale|locales|i18n)/", mk) or "with-locales" in mk]
749
+ if len(locale) >= 5 or any("with-locales" in mk for mk in locale):
750
+ res["flags"].append({"kind": "locales", "package": label, "size": sum(mods[mk]["size"] for mk in locale),
751
+ "initial": any(mods[mk]["initial"] for mk in locale),
752
+ "detail": "%d locale module(s) included: %s" % (len(locale), ", ".join(sorted(locale)[:3]))})
753
+ if name in POLYFILL_PACKAGES or name.endswith("-polyfill"):
754
+ feats = [mk for mk in inst["modules"] if "/modules/" in mk] or list(inst["modules"])
755
+ sample = sorted(set(os.path.basename(mk).rsplit(".", 1)[0] for mk in feats))
756
+ res["flags"].append({"kind": "polyfill", "package": label, "size": size, "initial": initial,
757
+ "detail": "%d module(s) in total; features: %s%s" % (len(members), ", ".join(sample[:16]), ", ..." if len(sample) > 16 else "")})
758
+ if why:
759
+ res["why"] = [why_one(b, q) for q in why]
760
+ return res
761
+
762
+
763
+ def why_one(b, query):
764
+ mods = b["modules"]
765
+ targets = []
766
+ pk_installs = defaultdict(list)
767
+ for mk, m in mods.items():
768
+ p = package_of(mk)
769
+ if p and p[0] == query:
770
+ pk_installs[p[1]].append(mk)
771
+ if pk_installs:
772
+ for install, members in sorted(pk_installs.items()):
773
+ best = None
774
+ for mk in members:
775
+ c = chain_for(b, mk)
776
+ if c and (best is None or len(c) < len(best[1])):
777
+ best = (mk, c)
778
+ targets.append({"install": install, "module": best[0] if best else members[0],
779
+ "chain": best[1] if best else None})
780
+ else:
781
+ hits = [mk for mk in mods if mk == query or mk.endswith("/" + query) or query in mk]
782
+ hits.sort(key=lambda mk: (mk != query, -mods[mk]["size"]))
783
+ for mk in hits[:3]:
784
+ targets.append({"module": mk, "chain": chain_for(b, mk)})
785
+ for t in targets:
786
+ t["importers"] = sorted(set("%s (%s)" % (s, r) if r else s for s, k, r in b["rev"].get(t["module"], [])))[:8]
787
+ t["initial"] = mods[t["module"]].get("initial", False)
788
+ return {"query": query, "matches": targets}
789
+
790
+
791
+ # ---------------------------------------------------------------- budgets and compare
792
+
793
+ SIZE_RE = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*(b|kb|kib|mb|mib)?\s*$", re.I)
794
+ UNIT = {None: 1, "b": 1, "kb": 1000, "kib": 1024, "mb": 1000000, "mib": 1048576}
795
+
796
+
797
+ def parse_budgets(items):
798
+ out = []
799
+ for it in items or []:
800
+ if "=" not in it:
801
+ raise InputError("--budget takes ENTRY=SIZE, e.g. main=170000, main=170kB or main=gzip:60kB; got %r" % it)
802
+ k, v = it.split("=", 1)
803
+ kind = "raw"
804
+ if v.lower().startswith("gzip:"):
805
+ kind, v = "gzip", v[5:]
806
+ m = SIZE_RE.match(v)
807
+ if not m:
808
+ raise InputError("--budget size must be bytes or a number with kB / KiB: %r" % it)
809
+ out.append((k.strip(), kind, float(m.group(1)) * UNIT[(m.group(2) or "").lower() or None]))
810
+ return out
811
+
812
+
813
+ def check_budgets(res, budgets):
814
+ rows = []
815
+ for key, kind, limit in budgets:
816
+ for e in res["entries"]:
817
+ if key in ("*", e["name"], stem(e["name"])):
818
+ val = e["js_raw"] if kind == "raw" else e["gzip"]
819
+ row = {"entry": e["name"], "kind": kind, "limit": int(limit), "value": val,
820
+ "basis": res["raw_basis"] if kind == "raw" else e["gzip_basis"],
821
+ "pass": None if val is None else val <= limit}
822
+ if val is None:
823
+ row["note"] = "compressed size not measured; pass --dist with the built files"
824
+ rows.append(row)
825
+ if key != "*" and not any(r["entry"] == key or stem(r["entry"]) == key for r in rows):
826
+ rows.append({"entry": key, "kind": kind, "limit": int(limit), "value": None, "basis": None, "pass": None,
827
+ "note": "no entry by this name"})
828
+ return rows
829
+
830
+
831
+ def compare(b1, b2, r1, r2, top):
832
+ out = {"before": {"tool": r1["tool"]}, "after": {"tool": r2["tool"]}, "entries": [], "chunks": [],
833
+ "packages": [], "modules_added": [], "modules_removed": [], "modules_changed": [],
834
+ "moved_to_initial": [], "moved_to_lazy": []}
835
+ e1 = {e["name"]: e for e in r1["entries"]}
836
+ e2 = {e["name"]: e for e in r2["entries"]}
837
+ for name in list(e1) + [n for n in e2 if n not in e1]:
838
+ a, c = e1.get(name), e2.get(name)
839
+ out["entries"].append({"name": name, "raw_before": a["js_raw"] if a else 0, "raw_after": c["js_raw"] if c else 0,
840
+ "gzip_before": a["gzip"] if a else 0, "gzip_after": c["gzip"] if c else 0,
841
+ "gzip_basis": basis_of([x["gzip_basis"] for x in (a, c) if x])})
842
+ for e in out["entries"]:
843
+ e["raw_delta"] = e["raw_after"] - e["raw_before"]
844
+ e["gzip_delta"] = (e["gzip_after"] - e["gzip_before"]
845
+ if e["gzip_after"] is not None and e["gzip_before"] is not None else None)
846
+ for key in list(b1["chunks"]) + [k for k in b2["chunks"] if k not in b1["chunks"]]:
847
+ a, c = b1["chunks"].get(key), b2["chunks"].get(key)
848
+ d = (c["raw"] if c else 0) - (a["raw"] if a else 0)
849
+ out["chunks"].append({"key": key, "initial_before": a["initial"] if a else None,
850
+ "initial_after": c["initial"] if c else None,
851
+ "raw_before": a["raw"] if a else 0, "raw_after": c["raw"] if c else 0, "raw_delta": d,
852
+ "loaded_from": sorted(set((c or a)["loaded_from"])),
853
+ "status": "added" if not a else "removed" if not c else "changed" if d else "same"})
854
+ out["chunks"].sort(key=lambda x: -abs(x["raw_delta"]))
855
+ out["chunks"] = [c for c in out["chunks"] if c["status"] != "same"]
856
+ p1 = {p["name"]: p for p in r1["packages"]}
857
+ p2 = {p["name"]: p for p in r2["packages"]}
858
+ for name in set(p1) | set(p2):
859
+ a, c = p1.get(name), p2.get(name)
860
+ row = {"name": name, "size_before": a["size"] if a else 0, "size_after": c["size"] if c else 0,
861
+ "initial_before": a["initial_size"] if a else 0, "initial_after": c["initial_size"] if c else 0,
862
+ "status": "added" if not a else "removed" if not c else "changed"}
863
+ row["delta"] = row["size_after"] - row["size_before"]
864
+ row["initial_delta"] = row["initial_after"] - row["initial_before"]
865
+ if row["delta"] or row["initial_delta"]:
866
+ if c:
867
+ mk = next((m for m in sorted(b2["modules"]) if (package_of(m) or ("",))[0] == name), None)
868
+ row["chain_after"] = chain_for(b2, mk) if mk else None
869
+ out["packages"].append(row)
870
+ if a and c and a["initial_size"] == 0 and c["initial_size"] > 0:
871
+ out["moved_to_initial"].append(name)
872
+ if a and c and a["initial_size"] > 0 and c["initial_size"] == 0:
873
+ out["moved_to_lazy"].append(name)
874
+ out["packages"].sort(key=lambda x: (-abs(x["initial_delta"]), -abs(x["delta"])))
875
+ m1, m2 = b1["modules"], b2["modules"]
876
+ added = sorted((k for k in m2 if k not in m1), key=lambda k: -m2[k]["size"])
877
+ for k in added[:top]:
878
+ out["modules_added"].append({"path": k, "size": m2[k]["size"], "initial": m2[k]["initial"],
879
+ "chain": chain_for(b2, k) if m2[k]["initial"] else None})
880
+ out["modules_added_total"] = {"count": len(added), "size": sum(m2[k]["size"] for k in added),
881
+ "initial_size": sum(m2[k]["size"] for k in added if m2[k]["initial"])}
882
+ removed = sorted((k for k in m1 if k not in m2), key=lambda k: -m1[k]["size"])
883
+ for k in removed[:top]:
884
+ out["modules_removed"].append({"path": k, "size": m1[k]["size"], "initial_before": m1[k]["initial"]})
885
+ changed = sorted((k for k in m1 if k in m2 and m1[k]["size"] != m2[k]["size"]),
886
+ key=lambda k: -abs(m2[k]["size"] - m1[k]["size"]))
887
+ moved = [k for k in m1 if k in m2 and not m1[k]["initial"] and m2[k]["initial"]]
888
+ out["modules_moved_to_initial"] = {"count": len(moved), "size": sum(m2[k]["size"] for k in moved),
889
+ "modules": [{"path": k, "size": m2[k]["size"]} for k in
890
+ sorted(moved, key=lambda k: -m2[k]["size"])[:top]]}
891
+ def totals(b):
892
+ init = sum(c["raw"] for c in b["chunks"].values() if c["initial"])
893
+ return init, sum(c["raw"] for c in b["chunks"].values()) - init
894
+ i1, l1 = totals(b1)
895
+ i2, l2 = totals(b2)
896
+ out["totals"] = {"all_before": i1 + l1, "all_after": i2 + l2, "initial_before": i1, "initial_after": i2,
897
+ "lazy_before": l1, "lazy_after": l2}
898
+ for k in changed[:top]:
899
+ out["modules_changed"].append({"path": k, "size_before": m1[k]["size"], "size_after": m2[k]["size"],
900
+ "initial_before": m1[k]["initial"], "initial_after": m2[k]["initial"]})
901
+ return out
902
+
903
+
904
+ # ---------------------------------------------------------------- project files around the stats
905
+
906
+ # First browser version with the feature native (MDN browser-compat data). core-js can keep a
907
+ # polyfill a little longer to patch spec bugs, so a rebuild confirms the removal.
908
+ POLYFILL_FEATURES = {
909
+ "es.promise": ("Promise", r"\bPromise\b", {"chrome": "67", "edge": "79", "firefox": "69", "safari": "11"}),
910
+ "es.promise.all-settled": ("Promise.allSettled", r"\bPromise\.allSettled\b", {"chrome": "76", "edge": "79", "firefox": "71", "safari": "13"}),
911
+ "es.promise.any": ("Promise.any", r"\bPromise\.any\b", {"chrome": "85", "edge": "85", "firefox": "79", "safari": "14"}),
912
+ "es.promise.with-resolvers": ("Promise.withResolvers", r"\bPromise\.withResolvers\b", {"chrome": "119", "edge": "119", "firefox": "121", "safari": "17.4"}),
913
+ "es.global-this": ("globalThis", r"\bglobalThis\b", {"chrome": "71", "edge": "79", "firefox": "65", "safari": "12.1"}),
914
+ "es.array.flat": ("Array.prototype.flat", r"\.flat\(", {"chrome": "69", "edge": "79", "firefox": "62", "safari": "12"}),
915
+ "es.array.flat-map": ("Array.prototype.flatMap", r"\.flatMap\(", {"chrome": "69", "edge": "79", "firefox": "62", "safari": "12"}),
916
+ "es.array.includes": ("Array.prototype.includes", r"\.includes\(", {"chrome": "47", "edge": "14", "firefox": "43", "safari": "9"}),
917
+ "es.array.at": ("Array.prototype.at", r"\.at\(", {"chrome": "92", "edge": "92", "firefox": "90", "safari": "15.4"}),
918
+ "es.string.at-alternative": ("String.prototype.at", r"\.at\(", {"chrome": "92", "edge": "92", "firefox": "90", "safari": "15.4"}),
919
+ "es.array.find-last": ("Array.prototype.findLast", r"\.findLast\(", {"chrome": "97", "edge": "97", "firefox": "104", "safari": "15.4"}),
920
+ "es.array.find-last-index": ("Array.prototype.findLastIndex", r"\.findLastIndex\(", {"chrome": "97", "edge": "97", "firefox": "104", "safari": "15.4"}),
921
+ "es.array.to-sorted": ("Array.prototype.toSorted", r"\.toSorted\(", {"chrome": "110", "edge": "110", "firefox": "115", "safari": "16"}),
922
+ "es.array.to-reversed": ("Array.prototype.toReversed", r"\.toReversed\(", {"chrome": "110", "edge": "110", "firefox": "115", "safari": "16"}),
923
+ "es.array.to-spliced": ("Array.prototype.toSpliced", r"\.toSpliced\(", {"chrome": "110", "edge": "110", "firefox": "115", "safari": "16"}),
924
+ "es.array.with": ("Array.prototype.with", r"\.with\(", {"chrome": "110", "edge": "110", "firefox": "115", "safari": "16"}),
925
+ "es.array.from-async": ("Array.fromAsync", r"\bArray\.fromAsync\b", {"chrome": "121", "edge": "121", "firefox": "115", "safari": "16.4"}),
926
+ "es.object.from-entries": ("Object.fromEntries", r"\bObject\.fromEntries\b", {"chrome": "73", "edge": "79", "firefox": "63", "safari": "12.1"}),
927
+ "es.object.has-own": ("Object.hasOwn", r"\bObject\.hasOwn\b", {"chrome": "93", "edge": "93", "firefox": "92", "safari": "15.4"}),
928
+ "es.object.group-by": ("Object.groupBy", r"\bObject\.groupBy\b", {"chrome": "117", "edge": "117", "firefox": "119", "safari": "17.4"}),
929
+ "es.map.group-by": ("Map.groupBy", r"\bMap\.groupBy\b", {"chrome": "117", "edge": "117", "firefox": "119", "safari": "17.4"}),
930
+ "es.string.replace-all": ("String.prototype.replaceAll", r"\.replaceAll\(", {"chrome": "85", "edge": "85", "firefox": "77", "safari": "13.1"}),
931
+ "es.string.is-well-formed": ("String.prototype.isWellFormed", r"\.isWellFormed\(", {"chrome": "111", "edge": "111", "firefox": "119", "safari": "16.4"}),
932
+ "es.set.union.v2": ("Set.prototype.union and the other Set methods", r"\.(union|intersection|difference|symmetricDifference|isSubsetOf|isSupersetOf|isDisjointFrom)\(", {"chrome": "122", "edge": "122", "firefox": "127", "safari": "17"}),
933
+ "es.symbol": ("Symbol", r"\bSymbol\b", {"chrome": "49", "edge": "15", "firefox": "51", "safari": "10"}),
934
+ "es.array.iterator": ("Array iterators", r"\bfor\s*\(.+\bof\b|\.(entries|keys|values)\(", {"chrome": "66", "edge": "79", "firefox": "60", "safari": "15.4"}),
935
+ "es.map": ("Map", r"\bnew\s+Map\b", {"chrome": "51", "edge": "15", "firefox": "53", "safari": "10"}),
936
+ "es.set": ("Set", r"\bnew\s+Set\b", {"chrome": "51", "edge": "15", "firefox": "53", "safari": "10"}),
937
+ "es.object.assign": ("Object.assign", r"\bObject\.assign\b", {"chrome": "49", "edge": "79", "firefox": "36", "safari": "9"}),
938
+ "es.object.entries": ("Object.entries", r"\bObject\.entries\b", {"chrome": "54", "edge": "14", "firefox": "47", "safari": "10.1"}),
939
+ "es.object.values": ("Object.values", r"\bObject\.values\b", {"chrome": "54", "edge": "14", "firefox": "47", "safari": "10.1"}),
940
+ "es.promise.finally": ("Promise.prototype.finally", r"\.finally\(", {"chrome": "67", "edge": "79", "firefox": "69", "safari": "13.1"}),
941
+ "es.string.pad-start": ("String.prototype.padStart", r"\.padStart\(", {"chrome": "57", "edge": "15", "firefox": "48", "safari": "15.4"}),
942
+ "es.string.pad-end": ("String.prototype.padEnd", r"\.padEnd\(", {"chrome": "57", "edge": "15", "firefox": "48", "safari": "15.4"}),
943
+ "es.string.includes": ("String.prototype.includes", r"\.includes\(", {"chrome": "51", "edge": "15", "firefox": "40", "safari": "10"}),
944
+ "web.url": ("URL", r"\bnew\s+URL\b", {"chrome": "67", "edge": "79", "firefox": "57", "safari": "14.1"}),
945
+ "web.url-search-params": ("URLSearchParams", r"\bURLSearchParams\b", {"chrome": "67", "edge": "79", "firefox": "57", "safari": "14.1"}),
946
+ "web.structured-clone": ("structuredClone", r"\bstructuredClone\(", {"chrome": "98", "edge": "98", "firefox": "94", "safari": "15.4"}),
947
+ }
948
+ BROWSER_ALIAS = {"chrome": "chrome", "edge": "edge", "firefox": "firefox", "ff": "firefox", "safari": "safari",
949
+ "ios_saf": "safari", "ios": "safari"}
950
+ SKIP_DIRS = {"node_modules", ".git", "dist", "build", "out", "coverage", ".next", ".nuxt", ".svelte-kit", ".cache", ".turbo"}
951
+ SRC_EXT = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte")
952
+ CONFIG_RE = re.compile(r"^((webpack|vite|rollup|esbuild|rspack|rsbuild|tsup|next|nuxt|astro|svelte|craco|babel)\.config(\.[\w-]+)?\.[cm]?[jt]s"
953
+ r"|build\.[cm]?[jt]s|tsconfig(\.[\w-]+)?\.json|\.babelrc(\.json)?|\.size-limit(\.json|\.[cm]?js)?)$")
954
+ SPEC_RES = [("static", re.compile(r"""\bfrom\s*['"]([^'"]+)['"]""")),
955
+ ("side-effect", re.compile(r"""(?m)^\s*import\s*['"]([^'"]+)['"]""")),
956
+ ("dynamic", re.compile(r"""\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)""")),
957
+ ("require", re.compile(r"""\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)"""))]
958
+ NS_RE = re.compile(r"""\bimport\s+\*\s+as\s+([\w$]+)\s+from\s*['"]([^'"]+)['"]""")
959
+
960
+
961
+ def ver_tuple(v):
962
+ out = []
963
+ for part in str(v).split("."):
964
+ if not part.isdigit():
965
+ break
966
+ out.append(int(part))
967
+ return tuple(out) or (0,)
968
+
969
+
970
+ def bare_package(spec):
971
+ if not spec or spec.startswith((".", "/")) or ":" in spec.split("/")[0]:
972
+ return None
973
+ parts = spec.split("/")
974
+ return "/".join(parts[:2]) if spec.startswith("@") and len(parts) > 1 else parts[0]
975
+
976
+
977
+ def read_text(path, limit=1000000):
978
+ try:
979
+ if os.path.getsize(path) > limit:
980
+ return None
981
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
982
+ return fh.read()
983
+ except OSError:
984
+ return None
985
+
986
+
987
+ def read_json(path):
988
+ t = read_text(path, 20000000)
989
+ if t is None:
990
+ return None
991
+ try:
992
+ return json.loads(t)
993
+ except ValueError:
994
+ return None
995
+
996
+
997
+ def find_root(stats_path, root):
998
+ if root:
999
+ return os.path.abspath(root)
1000
+ if not stats_path or stats_path == "-":
1001
+ return None
1002
+ d = os.path.dirname(os.path.abspath(stats_path))
1003
+ cands = [d, os.path.dirname(d), os.path.dirname(os.path.dirname(d))]
1004
+ for c in cands:
1005
+ if any(os.path.exists(os.path.join(c, f)) for f in ("package.json", ".browserslistrc")):
1006
+ return c
1007
+ for c in cands:
1008
+ if os.path.isdir(os.path.join(c, "src")):
1009
+ return c
1010
+ return d
1011
+
1012
+
1013
+ def parse_browserslist(queries):
1014
+ """{browser: min version tuple} when every query is a plain version query, else None."""
1015
+ targets = {}
1016
+ for q in queries:
1017
+ q = q.strip().lower()
1018
+ if not q:
1019
+ continue
1020
+ m = re.fullmatch(r"([a-z_]+)\s*(>=|>)\s*([0-9.]+)", q) or re.fullmatch(r"([a-z_]+)\s+([0-9.]+)(?:\s*-\s*[0-9.]+)?", q)
1021
+ if not m or m.group(1) not in BROWSER_ALIAS:
1022
+ return None
1023
+ if len(m.groups()) == 3:
1024
+ v = ver_tuple(m.group(3))
1025
+ if m.group(2) == ">":
1026
+ v = (v[0] + 1,)
1027
+ else:
1028
+ v = ver_tuple(m.group(2))
1029
+ b = BROWSER_ALIAS[m.group(1)]
1030
+ targets[b] = min(targets.get(b, v), v)
1031
+ return targets or None
1032
+
1033
+
1034
+ def browserslist_queries(raw):
1035
+ lines = []
1036
+ section = None
1037
+ prod = []
1038
+ for line in raw.splitlines():
1039
+ line = line.split("#", 1)[0].strip()
1040
+ if not line:
1041
+ continue
1042
+ if line.startswith("[") and line.endswith("]"):
1043
+ section = line[1:-1].strip().lower()
1044
+ continue
1045
+ for q in line.split(","):
1046
+ (prod if section == "production" else lines if section is None else []).append(q.strip())
1047
+ return prod or lines
1048
+
1049
+
1050
+ def build_targets(text):
1051
+ toks = []
1052
+ for m in re.finditer(r"""\btarget\s*:\s*(\[[^\]]*\]|['"][^'"]+['"])""", text):
1053
+ toks += re.findall(r"""['"]([^'"]+)['"]""", m.group(1))
1054
+ for m in re.finditer(r"""--target=([^\s'"]+)""", text):
1055
+ toks += m.group(1).split(",")
1056
+ out = []
1057
+ for t in toks:
1058
+ out += [x.strip() for x in t.split(",") if x.strip()]
1059
+ return out
1060
+
1061
+
1062
+ def esbuild_style_targets(tokens):
1063
+ targets = {}
1064
+ for t in tokens:
1065
+ m = re.fullmatch(r"(chrome|edge|firefox|safari|ios)([0-9][0-9.]*)", t.lower())
1066
+ if m:
1067
+ b = BROWSER_ALIAS[m.group(1)]
1068
+ v = ver_tuple(m.group(2))
1069
+ targets[b] = min(targets.get(b, v), v)
1070
+ return targets or None
1071
+
1072
+
1073
+ def lock_index(root):
1074
+ for name in ("package-lock.json", "npm-shrinkwrap.json"):
1075
+ data = read_json(os.path.join(root, name))
1076
+ if not isinstance(data, dict):
1077
+ continue
1078
+ pk = data.get("packages")
1079
+ if isinstance(pk, dict):
1080
+ return name, pk
1081
+ deps = data.get("dependencies")
1082
+ if isinstance(deps, dict): # lockfile v1: nested dependencies
1083
+ flat = {}
1084
+ def walk(d, prefix):
1085
+ for n, info in d.items():
1086
+ path = prefix + "node_modules/" + n
1087
+ flat[path] = {"version": info.get("version"), "dependencies": info.get("requires") or {}}
1088
+ if isinstance(info.get("dependencies"), dict):
1089
+ walk(info["dependencies"], path + "/")
1090
+ walk(deps, "")
1091
+ return name, flat
1092
+ return None, None
1093
+
1094
+
1095
+ def project_context(root, res, b):
1096
+ if not root or not os.path.isdir(root):
1097
+ return None
1098
+ ctx = {"root": root, "found": [], "absent": [], "browserslist": None, "build_targets": [], "ts_target": None,
1099
+ "targets": None, "targets_from": None, "budgets": [], "versions": {}, "lockfile": None,
1100
+ "dependents": {}, "sources_on_disk": [], "bundle_sources_absent": [], "importers": {}, "namespace": [],
1101
+ "polyfills": []}
1102
+ pkg = read_json(os.path.join(root, "package.json"))
1103
+ (ctx["found"] if isinstance(pkg, dict) else ctx["absent"]).append("package.json")
1104
+ queries, where = None, None
1105
+ rc = read_text(os.path.join(root, ".browserslistrc"))
1106
+ if rc is not None:
1107
+ ctx["found"].append(".browserslistrc")
1108
+ queries, where = browserslist_queries(rc), ".browserslistrc"
1109
+ else:
1110
+ ctx["absent"].append(".browserslistrc")
1111
+ if isinstance(pkg, dict):
1112
+ bl = pkg.get("browserslist")
1113
+ if isinstance(bl, dict):
1114
+ bl = bl.get("production") or bl.get("defaults")
1115
+ if isinstance(bl, str):
1116
+ bl = [bl]
1117
+ if isinstance(bl, list):
1118
+ if queries is None:
1119
+ queries, where = [str(x) for x in bl], "package.json browserslist"
1120
+ else:
1121
+ ctx["notes"] = ["package.json also has a browserslist field; browserslist refuses to run with both"]
1122
+ elif rc is None:
1123
+ ctx["absent"].append("package.json browserslist field")
1124
+ sl = pkg.get("size-limit") or pkg.get("bundlesize")
1125
+ if sl:
1126
+ ctx["budgets"].append("package.json %s: %s" % ("size-limit" if pkg.get("size-limit") else "bundlesize", json.dumps(sl)[:200]))
1127
+ if queries is not None:
1128
+ ctx["browserslist"] = {"from": where, "queries": queries}
1129
+ configs = []
1130
+ for d in (root, os.path.join(root, "config"), os.path.join(root, "scripts")):
1131
+ try:
1132
+ names = sorted(os.listdir(d))
1133
+ except OSError:
1134
+ continue
1135
+ for n in names:
1136
+ if CONFIG_RE.match(n) and os.path.isfile(os.path.join(d, n)):
1137
+ configs.append(os.path.relpath(os.path.join(d, n), root))
1138
+ if configs:
1139
+ ctx["found"] += configs
1140
+ else:
1141
+ ctx["absent"].append("bundler config (webpack/vite/rollup/esbuild config, build.mjs)")
1142
+ for rel in configs:
1143
+ text = read_text(os.path.join(root, rel)) or ""
1144
+ if rel.startswith("tsconfig") or "tsconfig" in rel:
1145
+ m = re.search(r'"target"\s*:\s*"([^"]+)"', text)
1146
+ if m:
1147
+ ctx["ts_target"] = {"from": rel, "target": m.group(1)}
1148
+ continue
1149
+ toks = build_targets(text)
1150
+ if toks:
1151
+ ctx["build_targets"].append({"from": rel, "target": toks})
1152
+ for key in ("maxEntrypointSize", "maxAssetSize", "hints"):
1153
+ for m in re.finditer(r"\b%s\s*:\s*([^,}\n]+)" % key, text):
1154
+ ctx["budgets"].append("%s %s: %s" % (rel, key, m.group(1).strip()))
1155
+ if os.path.basename(rel).startswith(".size-limit"):
1156
+ ctx["budgets"].append("%s: %s" % (rel, " ".join(text.split())[:200]))
1157
+ if queries is not None:
1158
+ ctx["targets"] = parse_browserslist(queries)
1159
+ ctx["targets_from"] = where if ctx["targets"] else None
1160
+ if ctx["targets"] is None and ctx["build_targets"]:
1161
+ toks = [t for bt in ctx["build_targets"] for t in bt["target"]]
1162
+ ctx["targets"] = esbuild_style_targets(toks)
1163
+ ctx["targets_from"] = ctx["build_targets"][0]["from"] + " target" if ctx["targets"] else None
1164
+ # versions: lockfile installed versions and declared ranges
1165
+ lname, lock = lock_index(root)
1166
+ if lname:
1167
+ ctx["found"].append(lname)
1168
+ ctx["lockfile"] = lname
1169
+ else:
1170
+ other = [n for n in ("yarn.lock", "pnpm-lock.yaml", "bun.lockb") if os.path.exists(os.path.join(root, n))]
1171
+ if other:
1172
+ ctx["found"] += other
1173
+ ctx["lockfile"] = other[0] + " (not parsed; read versions with yarn why / pnpm why)"
1174
+ else:
1175
+ ctx["absent"].append("lockfile")
1176
+ declared = {}
1177
+ if isinstance(pkg, dict):
1178
+ for sec in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
1179
+ for n, r in (pkg.get(sec) or {}).items():
1180
+ declared.setdefault(n, r)
1181
+ names = [p["name"] for p in res["packages"][:20]] + [d["package"] for d in res["duplicates"]]
1182
+ for n in dict.fromkeys(names):
1183
+ row = {"declared": declared.get(n)}
1184
+ if lock:
1185
+ row["installed"] = (lock.get("node_modules/" + n) or {}).get("version")
1186
+ ctx["versions"][n] = row
1187
+ for d in res["duplicates"]:
1188
+ for inst in d["installs"]:
1189
+ if lock and inst.get("version") is None:
1190
+ v = (lock.get(inst["install"]) or {}).get("version")
1191
+ if v:
1192
+ inst["version"] = v
1193
+ inst["version_from"] = lname
1194
+ if lock:
1195
+ wants = []
1196
+ if d["package"] in declared:
1197
+ wants.append("the app (package.json) %s" % declared[d["package"]])
1198
+ for path, info in sorted(lock.items()):
1199
+ if not path or not isinstance(info, dict):
1200
+ continue
1201
+ r = (info.get("dependencies") or {}).get(d["package"]) or (info.get("peerDependencies") or {}).get(d["package"])
1202
+ if r:
1203
+ owner = path.rsplit("node_modules/", 1)[-1]
1204
+ wants.append("%s@%s %s" % (owner, info.get("version") or "?", r))
1205
+ ctx["dependents"][d["package"]] = wants
1206
+ # source files on disk
1207
+ files = []
1208
+ for dp, dns, fns in os.walk(root):
1209
+ dns[:] = [x for x in dns if x not in SKIP_DIRS and not x.startswith(".")]
1210
+ for fn in fns:
1211
+ if fn.endswith(SRC_EXT) and not CONFIG_RE.match(fn):
1212
+ files.append(os.path.relpath(os.path.join(dp, fn), root).replace("\\", "/"))
1213
+ if len(files) > 3000:
1214
+ break
1215
+ files.sort()
1216
+ ctx["sources_on_disk"] = files
1217
+ on_disk = set(files)
1218
+ bundle_src = sorted(m for m in b["modules"] if "node_modules/" not in m and m.endswith(SRC_EXT))
1219
+ ctx["bundle_sources_absent"] = [m for m in bundle_src if m not in on_disk]
1220
+ bundled = set(p["name"] for p in res["packages"])
1221
+ texts = {}
1222
+ for f in files:
1223
+ t = read_text(os.path.join(root, f))
1224
+ if t is None:
1225
+ continue
1226
+ texts[f] = t
1227
+ for kind, rx in SPEC_RES:
1228
+ for spec in rx.findall(t):
1229
+ pk = bare_package(spec)
1230
+ if pk in bundled:
1231
+ ctx["importers"].setdefault(pk, [])
1232
+ item = (f, kind, spec)
1233
+ if item not in ctx["importers"][pk]:
1234
+ ctx["importers"][pk].append(item)
1235
+ for ns, spec in NS_RE.findall(t):
1236
+ pk = bare_package(spec)
1237
+ if pk and re.search(r"\b%s\s*\[" % re.escape(ns), t):
1238
+ ctx["namespace"].append({"file": f, "package": pk, "name": ns})
1239
+ # polyfills against the targets
1240
+ for m in sorted(b["modules"]):
1241
+ pk = package_of(m)
1242
+ if not pk or pk[0] not in ("core-js", "core-js-pure") or "/modules/" not in m:
1243
+ continue
1244
+ feat = os.path.basename(m).rsplit(".", 1)[0]
1245
+ key = feat.replace("esnext.", "es.", 1)
1246
+ row = {"module": feat, "size": b["modules"][m]["size"], "imported_by": [], "used_in": []}
1247
+ for f, t in texts.items():
1248
+ if re.search(r"""['"]core-js(-pure)?/(modules|stable|actual|es|features)/%s['"]""" % re.escape(feat), t):
1249
+ row["imported_by"].append(f)
1250
+ info = POLYFILL_FEATURES.get(key)
1251
+ tg = ctx["targets"]
1252
+ if not info:
1253
+ row["verdict"] = "not in this script's table; check against the targets by hand"
1254
+ elif not tg:
1255
+ row["feature"] = info[0]
1256
+ row["verdict"] = "browser targets unknown"
1257
+ else:
1258
+ row["feature"] = info[0]
1259
+ short = []
1260
+ unknown = []
1261
+ for br, minv in sorted(tg.items()):
1262
+ nat = info[2].get(br)
1263
+ if nat is None:
1264
+ unknown.append(br)
1265
+ elif minv < ver_tuple(nat):
1266
+ short.append("%s %s < %s" % (br, ".".join(map(str, minv)), nat))
1267
+ if short:
1268
+ row["verdict"] = "NEEDED: native only from %s" % ", ".join(short)
1269
+ rx = re.compile(info[1])
1270
+ row["used_in"] = sorted(f for f, t in texts.items() if rx.search(t) and f not in row["imported_by"])
1271
+ elif unknown:
1272
+ row["verdict"] = "unknown for %s" % ", ".join(unknown)
1273
+ else:
1274
+ row["verdict"] = "not needed: native in every target (%s)" % ", ".join(
1275
+ "%s %s" % (br, info[2][br]) for br in sorted(tg))
1276
+ ctx["polyfills"].append(row)
1277
+ return ctx
1278
+
1279
+
1280
+ # ---------------------------------------------------------------- text output
1281
+
1282
+ def fmt_chain(chain):
1283
+ if not chain:
1284
+ return "(no chain from an entry found in the stats)"
1285
+ s = chain[0]["module"]
1286
+ for step in chain[1:]:
1287
+ arrow = " -import()-> " if step.get("via") == "dynamic" else " > "
1288
+ s += arrow + step["module"]
1289
+ return s
1290
+
1291
+
1292
+ def gz_label(basis):
1293
+ return {"built-file": "measured from the built files, gzip -9", "visualizer": "from the visualizer's per-module figures"}.get(basis, basis or "not measured")
1294
+
1295
+
1296
+ def comp_line(gzip_v, gzip_b, br_v=None, br_b=None):
1297
+ if gzip_v is None:
1298
+ return "compressed: not measured"
1299
+ s = "gzip %s [%s]" % (bkb(gzip_v), gz_label(gzip_b))
1300
+ if br_v is not None:
1301
+ s += ", brotli %s [%s]" % (bkb(br_v), gz_label(br_b))
1302
+ return s
1303
+
1304
+
1305
+ def header(res):
1306
+ L = ["Bundle: %s. All sizes in bytes as the stats give them (kB = 1000 bytes)." % res["tool"],
1307
+ "Chunk sizes: %s." % res["raw_basis"],
1308
+ "Module and package sizes: %s." % res["module_basis"]]
1309
+ if not any(e["gzip"] is not None for e in res["entries"]):
1310
+ L.append("Compressed sizes: not measured; no built files were read (pass --dist DIR after a build).")
1311
+ for n in res["notes"]:
1312
+ L.append("Note: %s." % n)
1313
+ return L
1314
+
1315
+
1316
+ def render_project(ctx):
1317
+ L = [""]
1318
+ if ctx is None:
1319
+ L.append("Project files: none read (stats from stdin and no --root)")
1320
+ return L
1321
+ rel = os.path.relpath(ctx["root"])
1322
+ L.append("Project files (root: %s)" % (rel if not rel.startswith("..") else ctx["root"]))
1323
+ L.append(" found: %s" % (", ".join(ctx["found"]) or "(none)"))
1324
+ L.append(" absent: %s" % (", ".join(ctx["absent"]) or "(none)"))
1325
+ for n in ctx.get("notes", []):
1326
+ L.append(" note: %s" % n)
1327
+ bl = ctx["browserslist"]
1328
+ if bl:
1329
+ L.append(" browserslist (%s): %s" % (bl["from"], ", ".join(bl["queries"])))
1330
+ for bt in ctx["build_targets"]:
1331
+ L.append(" build target (%s): %s" % (bt["from"], ", ".join(bt["target"])))
1332
+ if ctx["ts_target"]:
1333
+ L.append(" TypeScript target (%s): %s (syntax only; does not decide polyfills)" % (ctx["ts_target"]["from"], ctx["ts_target"]["target"]))
1334
+ if ctx["targets"]:
1335
+ L.append(" browser targets used for the polyfill check (%s): %s" % (
1336
+ ctx["targets_from"], ", ".join("%s >= %s" % (k, ".".join(map(str, v))) for k, v in sorted(ctx["targets"].items()))))
1337
+ elif bl:
1338
+ L.append(" browser targets: the queries are not plain version queries; resolve them with: npx --no browserslist")
1339
+ else:
1340
+ L.append(" browser targets: none found in the project files")
1341
+ L.append(" existing size budgets: %s" % ("; ".join(ctx["budgets"]) if ctx["budgets"] else "none found"))
1342
+ if ctx["lockfile"] and not ctx["lockfile"].endswith("not parsed; read versions with yarn why / pnpm why)"):
1343
+ vers = ["%s %s%s" % (n, v.get("installed") or "not in lockfile",
1344
+ (" (declared %s)" % v["declared"]) if v.get("declared") else " (not a direct dependency)")
1345
+ for n, v in ctx["versions"].items()]
1346
+ if vers:
1347
+ L.append(" installed versions (%s; declared range from package.json): %s" % (ctx["lockfile"], "; ".join(vers)))
1348
+ else:
1349
+ vers = ["%s %s" % (n, v["declared"]) for n, v in ctx["versions"].items() if v.get("declared")]
1350
+ if vers:
1351
+ L.append(" declared ranges in package.json (installed versions unknown without a lockfile): %s" % "; ".join(vers))
1352
+ for pk, wants in ctx["dependents"].items():
1353
+ if wants:
1354
+ L.append(" who asks for %s: %s" % (pk, "; ".join(wants)))
1355
+ L.append(" source files on disk: %d%s" % (len(ctx["sources_on_disk"]),
1356
+ (": " + ", ".join(ctx["sources_on_disk"][:12]) + (" ..." if len(ctx["sources_on_disk"]) > 12 else "")) if ctx["sources_on_disk"] else ""))
1357
+ if ctx["bundle_sources_absent"]:
1358
+ a = ctx["bundle_sources_absent"]
1359
+ L.append(" bundled source files not on disk here (%d): %s%s" % (len(a), ", ".join(a[:10]), " ..." if len(a) > 10 else ""))
1360
+ for pk, imps in sorted(ctx["importers"].items()):
1361
+ per = {}
1362
+ for f, kind, spec in imps:
1363
+ per.setdefault(f, []).append((kind, spec))
1364
+ parts = []
1365
+ for f, items in per.items():
1366
+ if len(items) > 2:
1367
+ parts.append("%s (%d imports, e.g. '%s')" % (f, len(items), items[0][1]))
1368
+ else:
1369
+ parts.append("%s (%s)" % (f, ", ".join("%s '%s'" % it for it in items)))
1370
+ L.append(" %s imported in: %s" % (pk, "; ".join(parts[:8])))
1371
+ for n in ctx["namespace"]:
1372
+ L.append(" namespace import with a computed lookup: %s does import * as %s from '%s' and reads %s[...], so every export stays in the bundle"
1373
+ % (n["file"], n["name"], n["package"], n["name"]))
1374
+ if ctx["polyfills"]:
1375
+ L.append("")
1376
+ L.append("Polyfills against the browser targets (first native version per MDN browser-compat data; a rebuild confirms each removal)")
1377
+ for p in ctx["polyfills"]:
1378
+ extra = ""
1379
+ if p["imported_by"]:
1380
+ extra += "; imported in %s" % ", ".join(p["imported_by"])
1381
+ if p["used_in"]:
1382
+ extra += "; feature used in %s" % ", ".join(p["used_in"])
1383
+ L.append(" %-28s %10s %s%s%s" % (p["module"], kib(p["size"]), ("%s: " % p["feature"]) if p.get("feature") else "",
1384
+ p["verdict"], extra))
1385
+ removable = [p for p in ctx["polyfills"] if p["verdict"].startswith("not needed")]
1386
+ if removable:
1387
+ L.append(" not needed by the targets: %d module(s), %s in total" % (len(removable), kib(sum(p["size"] for p in removable))))
1388
+ return L
1389
+
1390
+
1391
+ def render_budgets(rows, title):
1392
+ L = ["", title]
1393
+ for r in rows:
1394
+ if r["pass"] is None:
1395
+ L.append(" %s: budget %s %s; %s" % (r["entry"], r["kind"], kib(r["limit"]), r.get("note")))
1396
+ else:
1397
+ L.append(" %s: %s vs budget %s (%s) -> %s" % (r["entry"], kib(r["value"]), kib(r["limit"]), r["kind"],
1398
+ "PASS" if r["pass"] else "OVER"))
1399
+ return L
1400
+
1401
+
1402
+ def render_why(why):
1403
+ L = []
1404
+ for w in why or []:
1405
+ L.append("")
1406
+ L.append("Why is %s in the bundle?" % w["query"])
1407
+ if not w["matches"]:
1408
+ L.append(" no module or package by that name in the stats")
1409
+ for t in w["matches"]:
1410
+ head = t.get("install") or t["module"]
1411
+ L.append(" %s (%s)" % (head, "initial" if t["initial"] else "lazy"))
1412
+ L.append(" chain: %s" % fmt_chain(t["chain"]))
1413
+ if t["importers"]:
1414
+ L.append(" imported by: %s" % ", ".join(t["importers"]))
1415
+ return L
1416
+
1417
+
1418
+ def render_single(res, budgets_rows):
1419
+ L = header(res)
1420
+ L.append("")
1421
+ L.append("Initial JS per entry (loaded before first render)")
1422
+ if not res["entries"]:
1423
+ L.append(" (no entries found)")
1424
+ for e in res["entries"]:
1425
+ L.append(" %s: %s%s%s" % (e["name"], bkb(e["js_raw"]),
1426
+ ("; " + comp_line(e["gzip"], e["gzip_basis"], e["brotli"], e["brotli_basis"])) if e["gzip"] is not None else "",
1427
+ ("; CSS %s" % kib(e["css_raw"])) if e["css_raw"] else ""))
1428
+ for c in e["chunks"]:
1429
+ L.append(" %-28s %14s %s" % (c["key"], kib(c["raw"]), ", ".join(c["files"])))
1430
+ L.append("")
1431
+ L.append("Lazy chunks (loaded on demand, not on first render)")
1432
+ if not res["lazy_chunks"]:
1433
+ L.append(" (none)")
1434
+ for c in res["lazy_chunks"]:
1435
+ mostly = ", ".join("%s %s" % (x["owner"], kib(x["size"])) for x in c["mostly"])
1436
+ frm = ("; loaded from " + ", ".join(c["loaded_from"])) if c["loaded_from"] else ""
1437
+ L.append(" %s: %s%s; files %s%s; largest: %s" % (c["key"], kib(c["raw"]),
1438
+ (", gzip %s" % kib(c["gzip"])) if c["gzip"] is not None else "", ", ".join(c["files"]), frm, mostly))
1439
+ L.append("")
1440
+ L.append("Largest modules (%s)" % res["module_basis"])
1441
+ for m in res["largest_modules"]:
1442
+ L.append(" %14s %-8s %s [%s]" % (kib(m["size"]), "initial" if m["initial"] else "lazy", m["path"], ", ".join(m["chunks"])))
1443
+ L.append("")
1444
+ L.append("Packages by size")
1445
+ for p in res["packages"][:15]:
1446
+ where = "initial %s" % kib(p["initial_size"]) if p["initial_size"] else "lazy only"
1447
+ if p["initial_size"] and p["lazy_size"]:
1448
+ where += ", lazy %s" % kib(p["lazy_size"])
1449
+ extra = []
1450
+ if p["installs"] > 1:
1451
+ extra.append("%d install paths" % p["installs"])
1452
+ if p["commonjs"]:
1453
+ extra.append("CommonJS")
1454
+ if p.get("gzip") is not None:
1455
+ extra.insert(0, "%s gzip [visualizer]" % kib(p["gzip"]))
1456
+ L.append(" %14s %-28s %s; %d module(s)%s" % (kib(p["size"]), p["name"], where, p["modules"],
1457
+ ("; " + ", ".join(extra)) if extra else ""))
1458
+ L.append("")
1459
+ L.append("Packages installed at more than one path")
1460
+ if not res["duplicates"]:
1461
+ L.append(" (none)")
1462
+ for d in res["duplicates"]:
1463
+ L.append(" %s: %d copies" % (d["package"], len(d["installs"])))
1464
+ for i in d["installs"]:
1465
+ ver = i["version"] or "version not in stats or lockfile (read it with npm ls %s)" % d["package"]
1466
+ if i.get("version_from"):
1467
+ ver = "%s (from %s)" % (i["version"], i["version_from"])
1468
+ L.append(" %s %s %s (%s)" % (i["install"], ver, kib(i["size"]), "initial" if i["initial_size"] else "lazy"))
1469
+ L.append(" pulled in by: %s" % (", ".join(i["pulled_in_by"]) or "(no importer in stats)"))
1470
+ L.append(" chain: %s" % fmt_chain(i["chain"]))
1471
+ if res["in_several_chunks"]:
1472
+ L.append("")
1473
+ L.append("Modules copied into more than one chunk")
1474
+ for s in res["in_several_chunks"]:
1475
+ L.append(" %s: %d module(s), %s each copy, %s extra, in %s" % (s["owner"], s["modules"], kib(s["size"]),
1476
+ kib(s["extra_copies_size"]), ", ".join(s["chunks"])))
1477
+ L.append("")
1478
+ L.append("Flags")
1479
+ if not res["flags"]:
1480
+ L.append(" (none)")
1481
+ for f in res["flags"]:
1482
+ L.append(" %s: %s, %s, %s; %s" % (f["kind"], f["package"], kib(f["size"]), "initial" if f["initial"] else "lazy", f["detail"]))
1483
+ L.extend(render_project(res.get("project")))
1484
+ if budgets_rows:
1485
+ L.extend(render_budgets(budgets_rows, "Budgets on each entry's initial JS"))
1486
+ L.extend(render_why(res.get("why")))
1487
+ return "\n".join(L) + "\n"
1488
+
1489
+
1490
+ def signed(n):
1491
+ if n is None:
1492
+ return "n/a"
1493
+ return ("+" if n > 0 else "") + kib(n)
1494
+
1495
+
1496
+ def render_compare(cmp_, r1, r2, budgets_before, budgets_after):
1497
+ L = ["Before: %s. After: %s." % (cmp_["before"]["tool"], cmp_["after"]["tool"])] + header(r2)[1:] + [""]
1498
+ t = cmp_["totals"]
1499
+ L.append("All JS chunks: %s -> %s (%s); initial %s, lazy %s" % (
1500
+ bkb(t["all_before"]), bkb(t["all_after"]), signed(t["all_after"] - t["all_before"]),
1501
+ signed(t["initial_after"] - t["initial_before"]), signed(t["lazy_after"] - t["lazy_before"])))
1502
+ L.append("")
1503
+ L.append("Initial JS per entry")
1504
+ for e in cmp_["entries"]:
1505
+ gz = ""
1506
+ if e["gzip_before"] is not None and e["gzip_after"] is not None:
1507
+ gz = "; gzip %s -> %s (%s) [%s]" % (kib(e["gzip_before"]), kib(e["gzip_after"]), signed(e["gzip_delta"]), gz_label(e["gzip_basis"]))
1508
+ L.append(" %s: %s -> %s (%s)%s" % (e["name"], bkb(e["raw_before"]), bkb(e["raw_after"]), signed(e["raw_delta"]), gz))
1509
+ L.append("")
1510
+ L.append("Chunks that changed")
1511
+ for c in cmp_["chunks"][:15]:
1512
+ kind = "initial" if (c["initial_after"] if c["initial_after"] is not None else c["initial_before"]) else "lazy"
1513
+ if c["initial_before"] is not None and c["initial_after"] is not None and c["initial_before"] != c["initial_after"]:
1514
+ kind = "lazy -> initial" if c["initial_after"] else "initial -> lazy"
1515
+ L.append(" %-30s %-15s %12s -> %12s (%s) %s%s" % (c["key"], kind, kib(c["raw_before"]), kib(c["raw_after"]),
1516
+ signed(c["raw_delta"]), c["status"],
1517
+ ("; loaded with import() from " + ", ".join(c["loaded_from"])) if c["loaded_from"] and kind != "initial" else ""))
1518
+ L.append("")
1519
+ L.append("Packages that changed (sorted by change in initial size)")
1520
+ for p in cmp_["packages"][:15]:
1521
+ L.append(" %-28s total %s -> %s (%s); initial %s -> %s (%s) %s" % (
1522
+ p["name"], kib(p["size_before"]), kib(p["size_after"]), signed(p["delta"]),
1523
+ kib(p["initial_before"]), kib(p["initial_after"]), signed(p["initial_delta"]), p["status"]))
1524
+ if p.get("chain_after") and p["initial_delta"] > 0:
1525
+ L.append(" chain: %s" % fmt_chain(p["chain_after"]))
1526
+ if cmp_["moved_to_initial"]:
1527
+ L.append(" moved from lazy to initial: %s" % ", ".join(cmp_["moved_to_initial"]))
1528
+ if cmp_["moved_to_lazy"]:
1529
+ L.append(" moved from initial to lazy: %s" % ", ".join(cmp_["moved_to_lazy"]))
1530
+ t = cmp_["modules_added_total"]
1531
+ L.append("")
1532
+ L.append("Modules added: %d, %s, of which %s in initial chunks" % (t["count"], kib(t["size"]), kib(t["initial_size"])))
1533
+ for m in cmp_["modules_added"]:
1534
+ L.append(" %12s %-8s %s" % (kib(m["size"]), "initial" if m["initial"] else "lazy", m["path"]))
1535
+ if m["chain"]:
1536
+ L.append(" chain: %s" % fmt_chain(m["chain"]))
1537
+ mv = cmp_["modules_moved_to_initial"]
1538
+ if mv["count"]:
1539
+ L.append("Modules moved from lazy to initial chunks: %d, %s" % (mv["count"], kib(mv["size"])))
1540
+ for m in mv["modules"]:
1541
+ L.append(" %12s %s" % (kib(m["size"]), m["path"]))
1542
+ if cmp_["modules_changed"]:
1543
+ L.append("Modules that changed size")
1544
+ for m in cmp_["modules_changed"]:
1545
+ mvs = ""
1546
+ if m["initial_before"] != m["initial_after"]:
1547
+ mvs = " lazy -> initial" if m["initial_after"] else " initial -> lazy"
1548
+ L.append(" %s: %s -> %s%s" % (m["path"], kib(m["size_before"]), kib(m["size_after"]), mvs))
1549
+ if cmp_["modules_removed"]:
1550
+ L.append("Modules removed")
1551
+ for m in cmp_["modules_removed"]:
1552
+ L.append(" %12s %s" % (kib(m["size"]), m["path"]))
1553
+ L.extend(render_project(r2.get("project")))
1554
+ if budgets_after:
1555
+ bmap = {(r["entry"], r["kind"], r["limit"]): r for r in budgets_before}
1556
+ L += ["", "Budgets on each entry's initial JS, before -> after"]
1557
+ for r in budgets_after:
1558
+ rb = bmap.get((r["entry"], r["kind"], r["limit"]))
1559
+ if r["pass"] is None:
1560
+ L.append(" %s: budget %s %s; %s" % (r["entry"], r["kind"], kib(r["limit"]), r.get("note")))
1561
+ continue
1562
+ L.append(" %s: budget %s (%s); before %s (%s), after %s (%s)" % (
1563
+ r["entry"], kib(r["limit"]), r["kind"], kib(rb["value"]) if rb else "n/a",
1564
+ ("PASS" if rb["pass"] else "OVER") if rb and rb["pass"] is not None else "n/a",
1565
+ kib(r["value"]), "PASS" if r["pass"] else "OVER"))
1566
+ return "\n".join(L) + "\n"
1567
+
1568
+
1569
+ # ---------------------------------------------------------------- main
1570
+
1571
+ def to_jsonable(o):
1572
+ if isinstance(o, (set, tuple)):
1573
+ return sorted(o) if isinstance(o, set) else list(o)
1574
+ raise TypeError(type(o))
1575
+
1576
+
1577
+ def main(argv=None):
1578
+ ap = argparse.ArgumentParser(description="Size a JS bundle from webpack stats, an esbuild metafile or rollup-plugin-visualizer raw data, and read the project files around it.")
1579
+ ap.add_argument("stats", nargs="?", help="stats file, or - for stdin")
1580
+ ap.add_argument("--compare", nargs=2, metavar=("BEFORE", "AFTER"), help="diff two stats files")
1581
+ ap.add_argument("--why", action="append", metavar="MODULE_OR_PACKAGE", help="print the import chain from an entry (repeatable)")
1582
+ ap.add_argument("--budget", action="append", metavar="ENTRY=SIZE",
1583
+ help="budget on an entry's initial JS: bytes or kB/KiB (main=170kB), or gzip:SIZE when measured; * for every entry (repeatable)")
1584
+ ap.add_argument("--dist", metavar="DIR", help="build output folder; gzip the real emitted files when found")
1585
+ ap.add_argument("--root", metavar="DIR", help="project folder to read package.json, .browserslistrc, lockfile, configs and sources from (default: found next to the stats file)")
1586
+ ap.add_argument("--top", type=int, default=10, help="rows in the largest-module lists (default 10)")
1587
+ ap.add_argument("--json", action="store_true", help="print JSON instead of text")
1588
+ ap.add_argument("-o", "--output", metavar="PATH", help="write the report to PATH instead of stdout")
1589
+ args = ap.parse_args(argv)
1590
+ try:
1591
+ budgets = parse_budgets(args.budget)
1592
+ over = False
1593
+ if args.compare:
1594
+ if args.stats:
1595
+ raise InputError("give either one stats file or --compare BEFORE AFTER, not both")
1596
+ b1, b2 = load(args.compare[0]), load(args.compare[1])
1597
+ r1 = analyze(b1, None, args.top)
1598
+ r2 = analyze(b2, args.dist, args.top, args.why)
1599
+ r2["project"] = project_context(find_root(args.compare[1], args.root), r2, b2)
1600
+ cmp_ = compare(b1, b2, r1, r2, args.top)
1601
+ bb, ba = check_budgets(r1, budgets), check_budgets(r2, budgets)
1602
+ over = any(r["pass"] is False for r in ba)
1603
+ if args.json:
1604
+ payload = {"compare": cmp_, "budgets_before": bb, "budgets_after": ba, "why": r2.get("why"),
1605
+ "project": r2["project"]}
1606
+ text = json.dumps(payload, indent=2, default=to_jsonable) + "\n"
1607
+ else:
1608
+ text = render_compare(cmp_, r1, r2, bb, ba)
1609
+ if r2.get("why"):
1610
+ text += "\n".join(render_why(r2["why"])) + "\n"
1611
+ else:
1612
+ if not args.stats:
1613
+ raise InputError("give a stats file path, - for stdin, or --compare BEFORE AFTER")
1614
+ b = load(args.stats)
1615
+ res = analyze(b, args.dist, args.top, args.why)
1616
+ res["project"] = project_context(find_root(args.stats, args.root), res, b)
1617
+ rows = check_budgets(res, budgets)
1618
+ res["budgets"] = rows
1619
+ over = any(r["pass"] is False for r in rows)
1620
+ text = json.dumps(res, indent=2, default=to_jsonable) + "\n" if args.json else render_single(res, rows)
1621
+ except InputError as e:
1622
+ sys.stderr.write("bundle_stats: %s\n" % e)
1623
+ return 2
1624
+ if args.output:
1625
+ with open(args.output, "w", encoding="utf-8") as fh:
1626
+ fh.write(text)
1627
+ else:
1628
+ sys.stdout.write(text)
1629
+ return 1 if over else 0
1630
+
1631
+
1632
+ if __name__ == "__main__":
1633
+ sys.exit(main())