kctl-react 0.6.2__py3-none-any.whl
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.
- kctl_react/__init__.py +3 -0
- kctl_react/__main__.py +5 -0
- kctl_react/cli.py +201 -0
- kctl_react/commands/__init__.py +0 -0
- kctl_react/commands/a11y.py +78 -0
- kctl_react/commands/affected.py +170 -0
- kctl_react/commands/apps.py +353 -0
- kctl_react/commands/build.py +376 -0
- kctl_react/commands/bundle_cmd.py +217 -0
- kctl_react/commands/cap.py +1465 -0
- kctl_react/commands/clean.py +76 -0
- kctl_react/commands/codegen.py +491 -0
- kctl_react/commands/compliance.py +587 -0
- kctl_react/commands/config_cmd.py +368 -0
- kctl_react/commands/dashboard.py +163 -0
- kctl_react/commands/deploy.py +318 -0
- kctl_react/commands/deps.py +792 -0
- kctl_react/commands/dev.py +96 -0
- kctl_react/commands/docker_cmd.py +73 -0
- kctl_react/commands/doctor.py +170 -0
- kctl_react/commands/e2e.py +343 -0
- kctl_react/commands/env.py +155 -0
- kctl_react/commands/i18n.py +310 -0
- kctl_react/commands/lint.py +306 -0
- kctl_react/commands/maintenance.py +308 -0
- kctl_react/commands/monitor_cmd.py +50 -0
- kctl_react/commands/observe.py +34 -0
- kctl_react/commands/packages.py +129 -0
- kctl_react/commands/perf.py +762 -0
- kctl_react/commands/pipeline.py +289 -0
- kctl_react/commands/pwa.py +193 -0
- kctl_react/commands/scaffold.py +323 -0
- kctl_react/commands/security.py +660 -0
- kctl_react/commands/skill_cmd.py +54 -0
- kctl_react/commands/state.py +254 -0
- kctl_react/commands/test_cmd.py +418 -0
- kctl_react/commands/ui_audit.py +889 -0
- kctl_react/core/__init__.py +0 -0
- kctl_react/core/analyzers.py +200 -0
- kctl_react/core/callbacks.py +70 -0
- kctl_react/core/compliance/__init__.py +3 -0
- kctl_react/core/compliance/api_check/__init__.py +3 -0
- kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
- kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
- kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
- kctl_react/core/compliance/api_check/checks/naming.py +60 -0
- kctl_react/core/compliance/api_check/checks/params.py +44 -0
- kctl_react/core/compliance/api_check/checks/requests.py +57 -0
- kctl_react/core/compliance/api_check/checks/types.py +55 -0
- kctl_react/core/compliance/api_check/hooks.py +133 -0
- kctl_react/core/compliance/api_check/matcher.py +55 -0
- kctl_react/core/compliance/api_check/schema.py +151 -0
- kctl_react/core/compliance/api_health/__init__.py +35 -0
- kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
- kctl_react/core/compliance/api_health/checks/auth.py +72 -0
- kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
- kctl_react/core/compliance/api_health/checks/response.py +55 -0
- kctl_react/core/compliance/api_health/checks/timing.py +38 -0
- kctl_react/core/compliance/api_health/client.py +99 -0
- kctl_react/core/compliance/api_health/sampler.py +16 -0
- kctl_react/core/compliance/checks/__init__.py +47 -0
- kctl_react/core/compliance/checks/api.py +101 -0
- kctl_react/core/compliance/checks/codegen.py +94 -0
- kctl_react/core/compliance/checks/darkmode.py +57 -0
- kctl_react/core/compliance/checks/errors.py +68 -0
- kctl_react/core/compliance/checks/features.py +66 -0
- kctl_react/core/compliance/checks/i18n_check.py +105 -0
- kctl_react/core/compliance/checks/imports.py +86 -0
- kctl_react/core/compliance/checks/navigation.py +62 -0
- kctl_react/core/compliance/checks/practices.py +122 -0
- kctl_react/core/compliance/checks/providers.py +85 -0
- kctl_react/core/compliance/checks/pwa.py +101 -0
- kctl_react/core/compliance/checks/responsive.py +47 -0
- kctl_react/core/compliance/checks/scripts.py +85 -0
- kctl_react/core/compliance/checks/shadcn.py +51 -0
- kctl_react/core/compliance/checks/structure.py +76 -0
- kctl_react/core/compliance/checks/testing.py +83 -0
- kctl_react/core/compliance/checks/theme.py +92 -0
- kctl_react/core/compliance/checks/ui_standard.py +185 -0
- kctl_react/core/compliance/checks/vite.py +83 -0
- kctl_react/core/compliance/engine.py +87 -0
- kctl_react/core/compliance/exceptions_map.py +15 -0
- kctl_react/core/compliance/fixes/__init__.py +33 -0
- kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
- kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
- kctl_react/core/compliance/fixes/imports_fix.py +36 -0
- kctl_react/core/compliance/fixes/structure_fix.py +20 -0
- kctl_react/core/compliance/fixes/theme_fix.py +29 -0
- kctl_react/core/compliance/models.py +106 -0
- kctl_react/core/config.py +201 -0
- kctl_react/core/discovery.py +185 -0
- kctl_react/core/exceptions.py +17 -0
- kctl_react/core/git.py +146 -0
- kctl_react/core/history.py +121 -0
- kctl_react/core/output.py +5 -0
- kctl_react/core/plugins.py +13 -0
- kctl_react/core/runner.py +34 -0
- kctl_react/py.typed +0 -0
- kctl_react-0.6.2.dist-info/METADATA +17 -0
- kctl_react-0.6.2.dist-info/RECORD +102 -0
- kctl_react-0.6.2.dist-info/WHEEL +4 -0
- kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Build commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from kctl_react.core.callbacks import AppContext
|
|
11
|
+
from kctl_react.core.discovery import get_app_dir
|
|
12
|
+
from kctl_react.core.runner import run_turbo
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Production builds and bundle analysis.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _find_build_dir(app_dir: Path) -> Path | None:
|
|
18
|
+
"""Find the build output directory (Vite: dist/, Next.js: .next/)."""
|
|
19
|
+
for name in ("dist", ".next"):
|
|
20
|
+
d = app_dir / name
|
|
21
|
+
if d.is_dir():
|
|
22
|
+
return d
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_dist_size(dist_dir: Path) -> int:
|
|
27
|
+
"""Get total size of a build output directory in bytes."""
|
|
28
|
+
if not dist_dir.is_dir():
|
|
29
|
+
return 0
|
|
30
|
+
total = 0
|
|
31
|
+
for f in dist_dir.rglob("*"):
|
|
32
|
+
if f.is_file():
|
|
33
|
+
total += f.stat().st_size
|
|
34
|
+
return total
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _format_size(size_bytes: int) -> str:
|
|
38
|
+
"""Format bytes as human-readable string."""
|
|
39
|
+
if size_bytes == 0:
|
|
40
|
+
return "[dim]--[/dim]"
|
|
41
|
+
size: float = float(size_bytes)
|
|
42
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
43
|
+
if size < 1024:
|
|
44
|
+
return f"{size:.1f} {unit}"
|
|
45
|
+
size /= 1024
|
|
46
|
+
return f"{size:.1f} TB"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_SUBCOMMANDS = {"size", "compare", "history", "chunks", "bundle"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.callback(invoke_without_command=True)
|
|
53
|
+
def build(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
app_name: Annotated[str | None, typer.Argument(help="App name (omit for all apps)")] = None,
|
|
56
|
+
analyze: Annotated[bool, typer.Option("--analyze", "-a", help="Show bundle size analysis after build.")] = False,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Build app(s) for production.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
kctl-react build sfa # Build SFA only
|
|
62
|
+
kctl-react build # Build all apps
|
|
63
|
+
kctl-react build sfa --analyze # Build + show bundle sizes
|
|
64
|
+
"""
|
|
65
|
+
if ctx.invoked_subcommand is not None:
|
|
66
|
+
return
|
|
67
|
+
if app_name and app_name in _SUBCOMMANDS:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
actx: AppContext = ctx.obj
|
|
71
|
+
out = actx.output
|
|
72
|
+
root = actx.project_root
|
|
73
|
+
|
|
74
|
+
if app_name:
|
|
75
|
+
actx.validate_app(app_name)
|
|
76
|
+
|
|
77
|
+
target = app_name or "all apps"
|
|
78
|
+
out.info(f"Building {target}...")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
run_turbo("build", root, filter_app=app_name, capture=False, timeout=600)
|
|
82
|
+
out.success(f"Build complete: {target}")
|
|
83
|
+
except Exception as e:
|
|
84
|
+
out.error(f"Build failed: {e}")
|
|
85
|
+
raise typer.Exit(1) from None
|
|
86
|
+
|
|
87
|
+
if analyze:
|
|
88
|
+
_show_sizes(actx, app_name)
|
|
89
|
+
# Save snapshot for history tracking
|
|
90
|
+
from kctl_react.core.history import save_snapshot
|
|
91
|
+
|
|
92
|
+
snapshot = _collect_sizes(root, app_name, actx.app_names)
|
|
93
|
+
save_snapshot(root, snapshot)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
def size(
|
|
98
|
+
ctx: typer.Context,
|
|
99
|
+
app_name: Annotated[str | None, typer.Argument(help="App name (omit for all apps)")] = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Show bundle sizes for built app(s)."""
|
|
102
|
+
actx: AppContext = ctx.obj
|
|
103
|
+
if app_name:
|
|
104
|
+
actx.validate_app(app_name)
|
|
105
|
+
_show_sizes(actx, app_name)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _show_sizes(actx: AppContext, app_name: str | None) -> None:
|
|
109
|
+
"""Display bundle size analysis."""
|
|
110
|
+
out = actx.output
|
|
111
|
+
root = actx.project_root
|
|
112
|
+
|
|
113
|
+
apps_to_check = [app_name] if app_name else actx.app_names
|
|
114
|
+
rows: list[list[str]] = []
|
|
115
|
+
json_data: list[dict] = []
|
|
116
|
+
|
|
117
|
+
for name in apps_to_check:
|
|
118
|
+
build_dir = _find_build_dir(get_app_dir(root, name))
|
|
119
|
+
has_build = build_dir is not None
|
|
120
|
+
total_size = _get_dist_size(build_dir) if build_dir else 0
|
|
121
|
+
|
|
122
|
+
# Count JS/CSS files
|
|
123
|
+
js_count = len(list(build_dir.rglob("*.js"))) if has_build else 0
|
|
124
|
+
css_count = len(list(build_dir.rglob("*.css"))) if has_build else 0
|
|
125
|
+
|
|
126
|
+
# Get JS/CSS total size
|
|
127
|
+
js_size = sum(f.stat().st_size for f in build_dir.rglob("*.js")) if has_build else 0
|
|
128
|
+
css_size = sum(f.stat().st_size for f in build_dir.rglob("*.css")) if has_build else 0
|
|
129
|
+
|
|
130
|
+
rows.append(
|
|
131
|
+
[
|
|
132
|
+
name,
|
|
133
|
+
_format_size(total_size),
|
|
134
|
+
f"{_format_size(js_size)} ({js_count} files)",
|
|
135
|
+
f"{_format_size(css_size)} ({css_count} files)",
|
|
136
|
+
]
|
|
137
|
+
)
|
|
138
|
+
json_data.append(
|
|
139
|
+
{
|
|
140
|
+
"app": name,
|
|
141
|
+
"total_bytes": total_size,
|
|
142
|
+
"js_bytes": js_size,
|
|
143
|
+
"js_files": js_count,
|
|
144
|
+
"css_bytes": css_size,
|
|
145
|
+
"css_files": css_count,
|
|
146
|
+
"built": has_build,
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
out.table(
|
|
151
|
+
"Bundle Sizes",
|
|
152
|
+
[("App", "cyan"), ("Total", "green"), ("JS", ""), ("CSS", "")],
|
|
153
|
+
rows,
|
|
154
|
+
data_for_json=json_data,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _collect_sizes(root: Path, app_name: str | None, app_names: list[str]) -> dict:
|
|
159
|
+
"""Collect size data for history snapshot."""
|
|
160
|
+
apps_to_check = [app_name] if app_name else app_names
|
|
161
|
+
apps_data: dict[str, int] = {}
|
|
162
|
+
for name in apps_to_check:
|
|
163
|
+
build_dir = _find_build_dir(get_app_dir(root, name))
|
|
164
|
+
apps_data[name] = _get_dist_size(build_dir) if build_dir else 0
|
|
165
|
+
return {"apps": apps_data}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def compare(
|
|
170
|
+
ctx: typer.Context,
|
|
171
|
+
app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Compare current bundle sizes against last recorded snapshot.
|
|
174
|
+
|
|
175
|
+
Run `kctl-react build --analyze` first to save a snapshot.
|
|
176
|
+
"""
|
|
177
|
+
from kctl_react.core.history import get_latest_snapshot
|
|
178
|
+
|
|
179
|
+
actx: AppContext = ctx.obj
|
|
180
|
+
out = actx.output
|
|
181
|
+
root = actx.project_root
|
|
182
|
+
|
|
183
|
+
if app_name:
|
|
184
|
+
actx.validate_app(app_name)
|
|
185
|
+
|
|
186
|
+
prev = get_latest_snapshot(root)
|
|
187
|
+
if not prev:
|
|
188
|
+
out.warn("No previous snapshot. Run `kctl-react build --analyze` first.")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
prev_apps = prev.get("apps", {})
|
|
192
|
+
apps_to_check = [app_name] if app_name else actx.app_names
|
|
193
|
+
|
|
194
|
+
rows: list[list[str]] = []
|
|
195
|
+
json_data: list[dict] = []
|
|
196
|
+
|
|
197
|
+
for name in apps_to_check:
|
|
198
|
+
build_dir = _find_build_dir(get_app_dir(root, name))
|
|
199
|
+
current = _get_dist_size(build_dir) if build_dir else 0
|
|
200
|
+
previous = prev_apps.get(name, 0)
|
|
201
|
+
diff = current - previous
|
|
202
|
+
|
|
203
|
+
if diff > 0:
|
|
204
|
+
diff_str = f"[red]+{_format_size(diff)}[/red]"
|
|
205
|
+
elif diff < 0:
|
|
206
|
+
diff_str = f"[green]{_format_size(abs(diff))}[/green]"
|
|
207
|
+
else:
|
|
208
|
+
diff_str = "[dim]no change[/dim]"
|
|
209
|
+
|
|
210
|
+
rows.append([name, _format_size(previous), _format_size(current), diff_str])
|
|
211
|
+
json_data.append({"app": name, "previous": previous, "current": current, "diff": diff})
|
|
212
|
+
|
|
213
|
+
out.table(
|
|
214
|
+
f"Size Comparison (vs {prev.get('timestamp', 'unknown')[:10]})",
|
|
215
|
+
[("App", "cyan"), ("Previous", ""), ("Current", "green"), ("Diff", "")],
|
|
216
|
+
rows,
|
|
217
|
+
data_for_json=json_data,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command()
|
|
222
|
+
def history(ctx: typer.Context) -> None:
|
|
223
|
+
"""Show build size history over time."""
|
|
224
|
+
from kctl_react.core.history import load_history
|
|
225
|
+
|
|
226
|
+
actx: AppContext = ctx.obj
|
|
227
|
+
out = actx.output
|
|
228
|
+
root = actx.project_root
|
|
229
|
+
|
|
230
|
+
records = load_history(root)
|
|
231
|
+
if not records:
|
|
232
|
+
out.warn("No build history. Run `kctl-react build --analyze` to start tracking.")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
rows: list[list[str]] = []
|
|
236
|
+
json_data: list[dict] = []
|
|
237
|
+
|
|
238
|
+
for record in records[-10:]: # Last 10
|
|
239
|
+
ts = record.get("timestamp", "")[:19].replace("T", " ")
|
|
240
|
+
apps_data = record.get("apps", {})
|
|
241
|
+
total = sum(apps_data.values())
|
|
242
|
+
app_count = sum(1 for v in apps_data.values() if v > 0)
|
|
243
|
+
rows.append([ts, _format_size(total), f"{app_count} apps"])
|
|
244
|
+
json_data.append({"timestamp": record.get("timestamp"), "total_bytes": total, "apps_built": app_count})
|
|
245
|
+
|
|
246
|
+
out.table(
|
|
247
|
+
"Build Size History",
|
|
248
|
+
[("Timestamp", "dim"), ("Total Size", "green"), ("Apps", "")],
|
|
249
|
+
rows,
|
|
250
|
+
data_for_json=json_data,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@app.command()
|
|
255
|
+
def chunks(
|
|
256
|
+
ctx: typer.Context,
|
|
257
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Show chunk breakdown for a built app."""
|
|
260
|
+
actx: AppContext = ctx.obj
|
|
261
|
+
out = actx.output
|
|
262
|
+
root = actx.project_root
|
|
263
|
+
|
|
264
|
+
actx.validate_app(app_name)
|
|
265
|
+
build_dir = _find_build_dir(get_app_dir(root, app_name))
|
|
266
|
+
|
|
267
|
+
if not build_dir:
|
|
268
|
+
out.warn(f"{app_name}: not built (run `kctl-react build {app_name}` first)")
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
dist_dir = build_dir # For relative_to below
|
|
272
|
+
|
|
273
|
+
# Find JS/CSS chunks — Vite: assets/, Next.js: static/chunks/
|
|
274
|
+
assets_dir = build_dir / "assets"
|
|
275
|
+
if not assets_dir.is_dir():
|
|
276
|
+
assets_dir = build_dir / "static" # Next.js
|
|
277
|
+
if not assets_dir.is_dir():
|
|
278
|
+
assets_dir = build_dir # Fallback to build root
|
|
279
|
+
|
|
280
|
+
js_files = sorted(assets_dir.rglob("*.js"), key=lambda f: f.stat().st_size, reverse=True)
|
|
281
|
+
css_files = sorted(assets_dir.rglob("*.css"), key=lambda f: f.stat().st_size, reverse=True)
|
|
282
|
+
|
|
283
|
+
rows: list[list[str]] = []
|
|
284
|
+
json_data: list[dict] = []
|
|
285
|
+
|
|
286
|
+
for f in js_files:
|
|
287
|
+
size = f.stat().st_size
|
|
288
|
+
name = f.relative_to(dist_dir)
|
|
289
|
+
rows.append([str(name), "JS", _format_size(size)])
|
|
290
|
+
json_data.append({"file": str(name), "type": "js", "bytes": size})
|
|
291
|
+
|
|
292
|
+
for f in css_files:
|
|
293
|
+
size = f.stat().st_size
|
|
294
|
+
name = f.relative_to(dist_dir)
|
|
295
|
+
rows.append([str(name), "CSS", _format_size(size)])
|
|
296
|
+
json_data.append({"file": str(name), "type": "css", "bytes": size})
|
|
297
|
+
|
|
298
|
+
total = sum(d["bytes"] for d in json_data)
|
|
299
|
+
rows.append(["[bold]Total[/bold]", "", f"[bold]{_format_size(total)}[/bold]"])
|
|
300
|
+
|
|
301
|
+
out.table(
|
|
302
|
+
f"Chunks: {app_name}",
|
|
303
|
+
[("File", ""), ("Type", "cyan"), ("Size", "green")],
|
|
304
|
+
rows,
|
|
305
|
+
data_for_json=json_data,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@app.command()
|
|
310
|
+
def bundle(
|
|
311
|
+
ctx: typer.Context,
|
|
312
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
313
|
+
top: Annotated[int, typer.Option("--top", "-n", help="Show top N largest files")] = 20,
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Show treemap-style bundle composition for a built app."""
|
|
316
|
+
actx: AppContext = ctx.obj
|
|
317
|
+
out = actx.output
|
|
318
|
+
root = actx.project_root
|
|
319
|
+
|
|
320
|
+
actx.validate_app(app_name)
|
|
321
|
+
build_dir = _find_build_dir(get_app_dir(root, app_name))
|
|
322
|
+
|
|
323
|
+
if not build_dir:
|
|
324
|
+
out.warn(f"{app_name}: not built — run `kctl-react build {app_name}` first")
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
total = _get_dist_size(build_dir)
|
|
328
|
+
if total == 0:
|
|
329
|
+
out.warn(f"{app_name}: build output is empty")
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
files: list[tuple[str, int, str]] = []
|
|
333
|
+
for f in build_dir.rglob("*"):
|
|
334
|
+
if not f.is_file():
|
|
335
|
+
continue
|
|
336
|
+
size = f.stat().st_size
|
|
337
|
+
rel = str(f.relative_to(build_dir))
|
|
338
|
+
ext = f.suffix.lstrip(".")
|
|
339
|
+
files.append((rel, size, ext))
|
|
340
|
+
|
|
341
|
+
files.sort(key=lambda x: -x[1])
|
|
342
|
+
|
|
343
|
+
by_ext: dict[str, int] = {}
|
|
344
|
+
for _, size, ext in files:
|
|
345
|
+
by_ext[ext] = by_ext.get(ext, 0) + size
|
|
346
|
+
|
|
347
|
+
rows: list[list[str]] = []
|
|
348
|
+
json_data: list[dict] = []
|
|
349
|
+
|
|
350
|
+
for rel, size, ext in files[:top]:
|
|
351
|
+
pct = (size / total * 100) if total else 0
|
|
352
|
+
bar_len = int(pct / 2)
|
|
353
|
+
bar = "[green]" + "█" * bar_len + "[/green]" + "░" * (50 - bar_len)
|
|
354
|
+
rows.append([rel, ext.upper() or "?", _format_size(size), f"{pct:.1f}%", bar])
|
|
355
|
+
json_data.append({"file": rel, "type": ext, "bytes": size, "percent": round(pct, 1)})
|
|
356
|
+
|
|
357
|
+
out.table(
|
|
358
|
+
f"Bundle Treemap — {app_name} ({_format_size(total)} total)",
|
|
359
|
+
[("File", ""), ("Type", "cyan"), ("Size", "green"), ("%", ""), ("Distribution", "dim")],
|
|
360
|
+
rows,
|
|
361
|
+
data_for_json=json_data,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
ext_rows: list[list[str]] = []
|
|
365
|
+
ext_json: list[dict] = []
|
|
366
|
+
for ext, size in sorted(by_ext.items(), key=lambda x: -x[1]):
|
|
367
|
+
pct = (size / total * 100) if total else 0
|
|
368
|
+
ext_rows.append([ext.upper() or "other", _format_size(size), f"{pct:.1f}%"])
|
|
369
|
+
ext_json.append({"type": ext or "other", "bytes": size, "percent": round(pct, 1)})
|
|
370
|
+
|
|
371
|
+
out.table(
|
|
372
|
+
"By File Type",
|
|
373
|
+
[("Type", "cyan"), ("Size", "green"), ("%", "")],
|
|
374
|
+
ext_rows,
|
|
375
|
+
data_for_json=ext_json,
|
|
376
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Advanced bundle analysis — budgets, duplicates, tree-shaking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from kctl_react.core.callbacks import AppContext
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Advanced bundle analysis.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _find_assets_dir(actx: AppContext, app_name: str) -> Path | None:
|
|
16
|
+
"""Find the JS/CSS assets directory for an app (Vite: dist/assets, Next.js: .next/static)."""
|
|
17
|
+
app_dir = actx.get_app_dir(app_name)
|
|
18
|
+
if actx.is_nextjs(app_name):
|
|
19
|
+
for candidate in (app_dir / ".next" / "static", app_dir / ".next"):
|
|
20
|
+
if candidate.is_dir():
|
|
21
|
+
return candidate
|
|
22
|
+
else:
|
|
23
|
+
for candidate in (app_dir / "dist" / "assets", app_dir / "dist"):
|
|
24
|
+
if candidate.is_dir():
|
|
25
|
+
return candidate
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def budget(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
33
|
+
max_js: Annotated[int, typer.Option("--max-js", help="Max JS size in KB")] = 500,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Check bundle sizes against budgets. Exits 1 if exceeded."""
|
|
36
|
+
actx: AppContext = ctx.obj
|
|
37
|
+
out = actx.output
|
|
38
|
+
actx.validate_app(app_name)
|
|
39
|
+
dist = _find_assets_dir(actx, app_name)
|
|
40
|
+
if dist is None or not dist.exists():
|
|
41
|
+
out.error(f"No build output for {app_name} — run build first")
|
|
42
|
+
raise typer.Exit(1)
|
|
43
|
+
js_size = sum(f.stat().st_size for f in dist.rglob("*.js"))
|
|
44
|
+
js_kb = js_size / 1024
|
|
45
|
+
status = "PASS" if js_kb <= max_js else "FAIL"
|
|
46
|
+
if out.json_mode:
|
|
47
|
+
out.raw_json({"app": app_name, "js_kb": round(js_kb, 1), "budget_kb": max_js, "status": status})
|
|
48
|
+
return
|
|
49
|
+
color = "green" if status == "PASS" else "red"
|
|
50
|
+
out.table(
|
|
51
|
+
"Bundle Budget",
|
|
52
|
+
[("App", "cyan"), ("JS Size", color), ("Budget", ""), ("Status", color)],
|
|
53
|
+
[[app_name, f"{js_kb:.1f} KB", f"{max_js} KB", status]],
|
|
54
|
+
)
|
|
55
|
+
if status == "FAIL":
|
|
56
|
+
out.error(f"Bundle exceeds budget by {js_kb - max_js:.1f} KB")
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def duplicates(ctx: typer.Context) -> None:
|
|
62
|
+
"""Detect packages bundled at different versions across apps."""
|
|
63
|
+
actx: AppContext = ctx.obj
|
|
64
|
+
out = actx.output
|
|
65
|
+
lock_file = actx.project_root / "pnpm-lock.yaml"
|
|
66
|
+
if not lock_file.exists():
|
|
67
|
+
out.error("No pnpm-lock.yaml found")
|
|
68
|
+
raise typer.Exit(1)
|
|
69
|
+
import yaml
|
|
70
|
+
|
|
71
|
+
lock = yaml.safe_load(lock_file.read_text())
|
|
72
|
+
packages = lock.get("packages", {})
|
|
73
|
+
# Find packages with multiple versions
|
|
74
|
+
pkg_versions: dict[str, list[str]] = {}
|
|
75
|
+
for key in packages:
|
|
76
|
+
# key format: /package@version or package@version
|
|
77
|
+
parts = key.rsplit("@", 1)
|
|
78
|
+
if len(parts) == 2:
|
|
79
|
+
name, ver = parts
|
|
80
|
+
name = name.lstrip("/")
|
|
81
|
+
pkg_versions.setdefault(name, []).append(ver)
|
|
82
|
+
dupes = {k: v for k, v in pkg_versions.items() if len(set(v)) > 1}
|
|
83
|
+
if not dupes:
|
|
84
|
+
out.success("No duplicate packages found")
|
|
85
|
+
return
|
|
86
|
+
rows = [[name, ", ".join(sorted(set(versions)))] for name, versions in sorted(dupes.items())]
|
|
87
|
+
out.table(f"Duplicate Packages ({len(dupes)})", [("Package", "yellow"), ("Versions", "dim")], rows)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command()
|
|
91
|
+
def treeshake(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
92
|
+
"""Detect imports that may prevent tree-shaking."""
|
|
93
|
+
actx: AppContext = ctx.obj
|
|
94
|
+
out = actx.output
|
|
95
|
+
actx.validate_app(app_name)
|
|
96
|
+
import re
|
|
97
|
+
|
|
98
|
+
src = actx.get_app_dir(app_name) / "src"
|
|
99
|
+
issues: list[dict] = []
|
|
100
|
+
for f in src.rglob("*.tsx"):
|
|
101
|
+
content = f.read_text(errors="ignore")
|
|
102
|
+
rel = str(f.relative_to(actx.project_root))
|
|
103
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
104
|
+
if re.search(r"import\s+\*\s+as", line):
|
|
105
|
+
issues.append({"file": rel, "line": i, "type": "import *", "code": line.strip()[:80]})
|
|
106
|
+
for f in src.rglob("*.ts"):
|
|
107
|
+
content = f.read_text(errors="ignore")
|
|
108
|
+
rel = str(f.relative_to(actx.project_root))
|
|
109
|
+
if f.name == "index.ts" and "export *" in content:
|
|
110
|
+
issues.append({"file": rel, "line": 0, "type": "barrel re-export", "code": "export * from ..."})
|
|
111
|
+
if not issues:
|
|
112
|
+
out.success("No tree-shaking issues found")
|
|
113
|
+
return
|
|
114
|
+
rows = [[i["file"], str(i["line"]), i["type"]] for i in issues]
|
|
115
|
+
out.table("Tree-Shaking Issues", [("File", "yellow"), ("Line", "dim"), ("Issue", "red")], rows)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def compare(
|
|
120
|
+
ctx: typer.Context,
|
|
121
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Compare current build against saved snapshot."""
|
|
124
|
+
import json
|
|
125
|
+
|
|
126
|
+
actx: AppContext = ctx.obj
|
|
127
|
+
out = actx.output
|
|
128
|
+
actx.validate_app(app_name)
|
|
129
|
+
|
|
130
|
+
snapshot_file = actx.project_root / ".kctl-react" / "bundle-snapshot.json"
|
|
131
|
+
if not snapshot_file.exists():
|
|
132
|
+
out.error("No snapshot found — run 'kctl-react bundle analyze --save' first")
|
|
133
|
+
raise typer.Exit(1) from None
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
snapshot: dict = json.loads(snapshot_file.read_text())
|
|
137
|
+
except Exception:
|
|
138
|
+
out.error("Could not read snapshot file")
|
|
139
|
+
raise typer.Exit(1) from None
|
|
140
|
+
|
|
141
|
+
app_snap = snapshot.get(app_name)
|
|
142
|
+
if app_snap is None:
|
|
143
|
+
out.error(f"No snapshot entry for '{app_name}'")
|
|
144
|
+
raise typer.Exit(1) from None
|
|
145
|
+
|
|
146
|
+
dist = _find_assets_dir(actx, app_name)
|
|
147
|
+
if dist is None or not dist.exists():
|
|
148
|
+
out.error(f"No build output for {app_name} — run build first")
|
|
149
|
+
raise typer.Exit(1) from None
|
|
150
|
+
|
|
151
|
+
js_current = sum(f.stat().st_size for f in dist.rglob("*.js"))
|
|
152
|
+
css_current = sum(f.stat().st_size for f in dist.rglob("*.css"))
|
|
153
|
+
|
|
154
|
+
js_snap = app_snap.get("js", 0)
|
|
155
|
+
css_snap = app_snap.get("css", 0)
|
|
156
|
+
|
|
157
|
+
def _delta_str(current: int, baseline: int) -> str:
|
|
158
|
+
diff_kb = (current - baseline) / 1024
|
|
159
|
+
if diff_kb > 0:
|
|
160
|
+
return f"[red]+{diff_kb:.1f} KB[/red]"
|
|
161
|
+
elif diff_kb < 0:
|
|
162
|
+
return f"[green]{diff_kb:.1f} KB[/green]"
|
|
163
|
+
return "[dim]0.0 KB[/dim]"
|
|
164
|
+
|
|
165
|
+
rows = [
|
|
166
|
+
[
|
|
167
|
+
app_name,
|
|
168
|
+
f"{js_current / 1024:.1f} KB",
|
|
169
|
+
_delta_str(js_current, js_snap),
|
|
170
|
+
f"{css_current / 1024:.1f} KB",
|
|
171
|
+
_delta_str(css_current, css_snap),
|
|
172
|
+
]
|
|
173
|
+
]
|
|
174
|
+
out.table(
|
|
175
|
+
"Bundle Comparison vs Snapshot",
|
|
176
|
+
[("App", "cyan"), ("JS", ""), ("JS Delta", ""), ("CSS", ""), ("CSS Delta", "")],
|
|
177
|
+
rows,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def impact(
|
|
183
|
+
ctx: typer.Context,
|
|
184
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
185
|
+
top: Annotated[int, typer.Option("--top", help="Show top N chunks")] = 15,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Show which chunks contribute most to bundle size."""
|
|
188
|
+
actx: AppContext = ctx.obj
|
|
189
|
+
out = actx.output
|
|
190
|
+
actx.validate_app(app_name)
|
|
191
|
+
|
|
192
|
+
dist = _find_assets_dir(actx, app_name)
|
|
193
|
+
if dist is None or not dist.exists():
|
|
194
|
+
out.error(f"No build output for {app_name} — run build first")
|
|
195
|
+
raise typer.Exit(1) from None
|
|
196
|
+
|
|
197
|
+
files = sorted((f for f in dist.rglob("*") if f.is_file()), key=lambda f: f.stat().st_size, reverse=True)
|
|
198
|
+
total = sum(f.stat().st_size for f in files)
|
|
199
|
+
|
|
200
|
+
if total == 0:
|
|
201
|
+
out.info("No assets found")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
rows = []
|
|
205
|
+
cumulative = 0
|
|
206
|
+
for f in files[:top]:
|
|
207
|
+
size = f.stat().st_size
|
|
208
|
+
cumulative += size
|
|
209
|
+
pct = size / total * 100
|
|
210
|
+
cum_pct = cumulative / total * 100
|
|
211
|
+
rows.append([f.name, f"{size / 1024:.1f} KB", f"{pct:.1f}%", f"{cum_pct:.1f}%"])
|
|
212
|
+
|
|
213
|
+
out.table(
|
|
214
|
+
f"Bundle Impact — {app_name} (top {min(top, len(files))} of {len(files)} files)",
|
|
215
|
+
[("File", "cyan"), ("Size", ""), ("% of Total", ""), ("Cumulative %", "dim")],
|
|
216
|
+
rows,
|
|
217
|
+
)
|