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.
- package/README.md +1 -1
- package/catalogue.json +3 -1
- package/model-routing.json +8 -0
- package/package.json +1 -1
- package/packs/ai-engineering/skills/judge-audit/SKILL.md +157 -0
- package/packs/ai-engineering/skills/judge-audit/evals/eval_queries.json +11 -0
- package/packs/ai-engineering/skills/judge-audit/evals/evals.json +136 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/1/judgements.csv +321 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/1/notes.md +28 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/2/pairwise.csv +101 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/2/run-notes.md +16 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/4/grades.csv +97 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/5/pairwise_judgements.jsonl +120 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/6/rag-judge/README.md +36 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/6/rag-judge/judgments.csv +301 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/7/moderation/gold_labels.csv +101 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/7/moderation/run_1.jsonl +100 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/7/moderation/run_2.jsonl +100 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/8/patches.csv +241 -0
- package/packs/ai-engineering/skills/judge-audit/evals/files/9/adequacy_ratings.csv +151 -0
- package/packs/ai-engineering/skills/judge-audit/examples/summary-panel.md +70 -0
- package/packs/ai-engineering/skills/judge-audit/manifest.json +44 -0
- package/packs/ai-engineering/skills/judge-audit/reference.md +176 -0
- package/packs/ai-engineering/skills/judge-audit/scripts/judge_stats.py +1735 -0
- package/packs/frontend/pack.json +9 -3
- package/packs/frontend/skills/bundle-size-review/SKILL.md +114 -0
- package/packs/frontend/skills/bundle-size-review/evals/eval_queries.json +12 -0
- package/packs/frontend/skills/bundle-size-review/evals/evals.json +108 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/1/package.json +28 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/1/src/utils/format.js +26 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/1/stats.json +1334 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/2/PR.md +21 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/2/meta-after.json +440 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/2/meta-before.json +360 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/3/package.json +29 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/3/stats.json +1583 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/4/src/components/CommentBox.jsx +24 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/4/stats/after.json +299 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/4/stats/before.json +275 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/5/.browserslistrc +3 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/5/build/meta.json +371 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/5/build.mjs +18 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/5/package-lock.json +39 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/5/package.json +22 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/5/src/components/Icon.jsx +8 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/5/src/polyfills.js +7 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/5/src/utils/groupRows.js +4 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/.browserslistrc +3 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/package.json +25 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/src/App.tsx +8 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/chartLocales.ts +16 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/index.ts +8 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/messages/de.ts +5 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/messages/en.ts +5 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/useLocale.ts +14 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/src/main.tsx +10 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/src/pages/Home.tsx +14 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/src/pages/Reports.tsx +31 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/src/routes.tsx +17 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/stats.json +1869 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/6/vite.config.ts +13 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/package.json +26 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/src/App.tsx +27 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/src/pages/Analytics.tsx +10 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/src/pages/Users.tsx +13 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/stats.json +1421 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/webpack.config.js +25 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/package.json +27 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/App.tsx +18 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/index.tsx +4 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/pages/Home.tsx +10 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/pages/Product.tsx +13 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/stats.json +1496 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/webpack.config.js +26 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/package.json +9 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/Button.css +3 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/Button.js +14 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/Chart.js +21 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/index.js +22 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/package.json +20 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/Button.css +3 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/Button.tsx +9 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/Chart.tsx +14 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/index.ts +6 -0
- package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/tsconfig.json +14 -0
- package/packs/frontend/skills/bundle-size-review/examples/inputs/stats-after.json +1358 -0
- package/packs/frontend/skills/bundle-size-review/examples/inputs/stats-before.json +1334 -0
- package/packs/frontend/skills/bundle-size-review/examples/larkspur-sparkline.md +52 -0
- package/packs/frontend/skills/bundle-size-review/manifest.json +52 -0
- package/packs/frontend/skills/bundle-size-review/reference.md +147 -0
- package/packs/frontend/skills/bundle-size-review/scripts/bundle_stats.py +1633 -0
- package/packs/frontend/skills/web-performance-review/SKILL.md +3 -3
- 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())
|