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.
Files changed (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. 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
+ )