@qubiqlabs/mobiflow 0.9.0 → 1.0.0

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 (39) hide show
  1. package/README.md +9 -11
  2. package/bin/mobiflow.js +94 -61
  3. package/package.json +8 -3
  4. package/pyproject.toml +59 -0
  5. package/src/mobiflow/__init__.py +9 -0
  6. package/src/mobiflow/__main__.py +6 -0
  7. package/src/mobiflow/baseline.py +228 -0
  8. package/src/mobiflow/casedata.py +159 -0
  9. package/src/mobiflow/cases/__init__.py +715 -0
  10. package/src/mobiflow/cli.py +1423 -0
  11. package/src/mobiflow/cloud/__init__.py +28 -0
  12. package/src/mobiflow/cloud/base.py +272 -0
  13. package/src/mobiflow/cloud/browserstack.py +330 -0
  14. package/src/mobiflow/cloud/maestro_cloud.py +141 -0
  15. package/src/mobiflow/cloud/media.py +269 -0
  16. package/src/mobiflow/cloud/runner.py +156 -0
  17. package/src/mobiflow/cloud/testmu.py +378 -0
  18. package/src/mobiflow/config/__init__.py +538 -0
  19. package/src/mobiflow/deps.py +377 -0
  20. package/src/mobiflow/devices.py +717 -0
  21. package/src/mobiflow/explore.py +623 -0
  22. package/src/mobiflow/incremental.py +198 -0
  23. package/src/mobiflow/init/__init__.py +794 -0
  24. package/src/mobiflow/llm.py +462 -0
  25. package/src/mobiflow/llm_catalog.py +232 -0
  26. package/src/mobiflow/maestro/__init__.py +1506 -0
  27. package/src/mobiflow/maestro/lifecycle.py +279 -0
  28. package/src/mobiflow/pipeline.py +600 -0
  29. package/src/mobiflow/report/__init__.py +617 -0
  30. package/src/mobiflow/report/static/favicon.jpg +0 -0
  31. package/src/mobiflow/report/static/favicon.svg +1 -0
  32. package/src/mobiflow/report/static/icons.svg +24 -0
  33. package/src/mobiflow/report/static/index.html +99 -0
  34. package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
  35. package/src/mobiflow/reporting.py +682 -0
  36. package/src/mobiflow/sample_apps.py +259 -0
  37. package/src/mobiflow/secrets.py +90 -0
  38. package/src/mobiflow/selectors.py +128 -0
  39. package/src/mobiflow/suite.py +263 -0
@@ -0,0 +1,617 @@
1
+ """Rich execution report packs (Vite/React SPA + JSON inject)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import re
8
+ import sys
9
+ import uuid
10
+ from dataclasses import asdict, dataclass, field
11
+ from datetime import UTC, datetime
12
+ from importlib import resources
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from mobiflow.reporting import ReportCase
17
+
18
+
19
+ def utc_now_iso() -> str:
20
+ return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
21
+
22
+
23
+ @dataclass
24
+ class TokenUsage:
25
+ prompt_tokens: int = 0
26
+ completion_tokens: int = 0
27
+ total_tokens: int = 0
28
+ cost: float = 0.0
29
+
30
+ def merged(self, other: TokenUsage) -> TokenUsage:
31
+ return TokenUsage(
32
+ prompt_tokens=self.prompt_tokens + other.prompt_tokens,
33
+ completion_tokens=self.completion_tokens + other.completion_tokens,
34
+ total_tokens=self.total_tokens + other.total_tokens,
35
+ cost=self.cost + other.cost,
36
+ )
37
+
38
+
39
+ @dataclass
40
+ class StepRecord:
41
+ index: int
42
+ action: str
43
+ status: str # pass | fail | skipped | info
44
+ raw: str = ""
45
+ detail: str = ""
46
+ duration_ms: int = 0
47
+ screenshot: str | None = None
48
+ url: str | None = None
49
+ value: str | None = None
50
+ locator: str | None = None
51
+
52
+
53
+ @dataclass
54
+ class PhaseStatus:
55
+ name: str
56
+ status: str # success | failed | skipped | not_run
57
+ detail: str = ""
58
+ duration_ms: int = 0
59
+ usage: TokenUsage = field(default_factory=TokenUsage)
60
+
61
+
62
+ @dataclass
63
+ class CaseRecord:
64
+ id: str
65
+ name: str
66
+ status: str # passed | failed | skipped | error
67
+ mode: str # ai | scripted
68
+ url: str = ""
69
+ tags: list[str] = field(default_factory=list)
70
+ title: str | None = None
71
+ started_at: str = field(default_factory=utc_now_iso)
72
+ finished_at: str = field(default_factory=utc_now_iso)
73
+ duration_ms: int = 0
74
+ phases: list[PhaseStatus] = field(default_factory=list)
75
+ steps: list[StepRecord] = field(default_factory=list)
76
+ explore_usage: TokenUsage = field(default_factory=TokenUsage)
77
+ codegen_usage: TokenUsage = field(default_factory=TokenUsage)
78
+ files_generated: list[str] = field(default_factory=list)
79
+ heal_attempts: int = 0
80
+ failure_output: str = ""
81
+ errors: list[str] = field(default_factory=list)
82
+ artifacts: dict[str, Any] = field(default_factory=dict)
83
+ source_case: str | None = None
84
+ prompt: str | None = None
85
+
86
+ @property
87
+ def total_usage(self) -> TokenUsage:
88
+ return self.explore_usage.merged(self.codegen_usage)
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ data = asdict(self)
92
+ data["total_usage"] = asdict(self.total_usage)
93
+ return data
94
+
95
+
96
+ @dataclass
97
+ class EnvInfo:
98
+ generated_at: str = field(default_factory=utc_now_iso)
99
+ python: str = ""
100
+ platform: str = "" # host OS
101
+ mobiflow_version: str = ""
102
+ maestro: str = ""
103
+ llm_provider: str = ""
104
+ stack_tool: str = "maestro"
105
+ stack_language: str = ""
106
+ stack_runner: str = "maestro"
107
+ repo_path: str = ""
108
+ # Mobile target
109
+ device: str = ""
110
+ app_id: str = ""
111
+ mobile_platform: str = ""
112
+ device_provider: str = ""
113
+ # Legacy aliases (older SPA / packs)
114
+ browser: str = "" # → device
115
+ url: str = "" # → app_id
116
+ playwright: str = ""
117
+ explore_model: str = ""
118
+ codegen_model: str = ""
119
+ headless: bool = False
120
+ azure_endpoint: str | None = None
121
+
122
+ def to_dict(self) -> dict[str, Any]:
123
+ data = asdict(self)
124
+ # Prefer explicit mobile fields; keep aliases populated for older UIs
125
+ device = self.device or self.browser
126
+ app_id = self.app_id or self.url
127
+ data["device"] = device
128
+ data["app_id"] = app_id
129
+ data["browser"] = device
130
+ data["url"] = app_id
131
+ # Drop browser-automation noise from packs unless set
132
+ for key in ("playwright", "explore_model", "codegen_model", "azure_endpoint"):
133
+ if not data.get(key):
134
+ data.pop(key, None)
135
+ if not data.get("headless"):
136
+ data.pop("headless", None)
137
+ return data
138
+
139
+
140
+ @dataclass
141
+ class PackSummary:
142
+ total: int = 0
143
+ passed: int = 0
144
+ failed: int = 0
145
+ skipped: int = 0
146
+ error: int = 0
147
+ duration_ms: int = 0
148
+ prompt_tokens: int = 0
149
+ completion_tokens: int = 0
150
+ total_tokens: int = 0
151
+ cost: float = 0.0
152
+ pass_rate: float = 0.0
153
+
154
+
155
+ @dataclass
156
+ class TrendPoint:
157
+ label: str
158
+ pass_rate: float
159
+ duration_ms: int
160
+ cost: float
161
+ total: int
162
+ passed: int
163
+ failed: int
164
+ at: str
165
+ total_tokens: int = 0
166
+ prompt_tokens: int = 0
167
+ completion_tokens: int = 0
168
+
169
+
170
+ @dataclass
171
+ class Insight:
172
+ severity: str
173
+ title: str
174
+ body: str
175
+
176
+
177
+ @dataclass
178
+ class ReportPack:
179
+ id: str
180
+ title: str
181
+ generated_at: str
182
+ env: EnvInfo
183
+ summary: PackSummary
184
+ cases: list[CaseRecord] = field(default_factory=list)
185
+ trends: list[TrendPoint] = field(default_factory=list)
186
+ insights: list[Insight] = field(default_factory=list)
187
+
188
+ def to_dict(self) -> dict[str, Any]:
189
+ return {
190
+ "id": self.id,
191
+ "title": self.title,
192
+ "generated_at": self.generated_at,
193
+ "env": self.env.to_dict(),
194
+ "summary": asdict(self.summary),
195
+ "cases": [c.to_dict() for c in self.cases],
196
+ "trends": [asdict(t) for t in self.trends],
197
+ "insights": [asdict(i) for i in self.insights],
198
+ }
199
+
200
+
201
+ _PLACEHOLDER_RE = re.compile(
202
+ r"<script>\s*window\.__MOBIFLOW_REPORT__\s*=\s*window\.__MOBIFLOW_REPORT__\s*\|\|\s*null\s*;\s*</script>",
203
+ re.MULTILINE,
204
+ )
205
+
206
+
207
+ def _load_template() -> str:
208
+ try:
209
+ root = resources.files("mobiflow.report")
210
+ static = root.joinpath("static", "index.html")
211
+ if static.is_file():
212
+ return static.read_text(encoding="utf-8")
213
+ except Exception: # noqa: BLE001
214
+ pass
215
+ fallback = Path(__file__).resolve().parent / "static" / "index.html"
216
+ if fallback.is_file():
217
+ return fallback.read_text(encoding="utf-8")
218
+ raise FileNotFoundError(
219
+ "Report UI bundle missing. Build it with: "
220
+ "cd report-ui && npm install && npm run build"
221
+ )
222
+
223
+
224
+ def render_html(pack: ReportPack) -> str:
225
+ template = _load_template()
226
+ payload = json.dumps(pack.to_dict(), ensure_ascii=False, default=str)
227
+ safe = payload.replace("<", "\\u003c").replace(">", "\\u003e")
228
+ injection = f"<script>window.__MOBIFLOW_REPORT__ = {safe};</script>"
229
+ html = _PLACEHOLDER_RE.sub("", template)
230
+ if re.search(r"<head[^>]*>", html, flags=re.IGNORECASE):
231
+ html = re.sub(
232
+ r"(<head[^>]*>)",
233
+ lambda m: f"{m.group(1)}\n {injection}",
234
+ html,
235
+ count=1,
236
+ flags=re.IGNORECASE,
237
+ )
238
+ else:
239
+ html = injection + html
240
+ title = pack.title.replace("<", "&lt;")
241
+ html = re.sub(
242
+ r"<title>.*?</title>",
243
+ f"<title>{title}</title>",
244
+ html,
245
+ count=1,
246
+ flags=re.IGNORECASE | re.DOTALL,
247
+ )
248
+ return html
249
+
250
+
251
+ def write_pack_html(pack: ReportPack, out_path: Path) -> Path:
252
+ out_path = Path(out_path)
253
+ out_path.parent.mkdir(parents=True, exist_ok=True)
254
+ out_path.write_text(render_html(pack), encoding="utf-8")
255
+ return out_path
256
+
257
+
258
+ def write_pack_json(pack: ReportPack, out_path: Path) -> Path:
259
+ out_path = Path(out_path)
260
+ out_path.parent.mkdir(parents=True, exist_ok=True)
261
+ out_path.write_text(
262
+ json.dumps(pack.to_dict(), indent=2, ensure_ascii=False) + "\n",
263
+ encoding="utf-8",
264
+ )
265
+ return out_path
266
+
267
+
268
+ def _abs_shots(case: ReportCase) -> list[str]:
269
+ shots: list[str] = []
270
+ base = Path(case.artifact_dir) if case.artifact_dir else None
271
+ for rel in case.screenshot_paths or []:
272
+ p = Path(rel)
273
+ if not p.is_absolute() and base is not None:
274
+ # report may store ../screenshots/x relative to report dir
275
+ cand = (base / "screenshots" / p.name).resolve()
276
+ if cand.is_file():
277
+ shots.append(str(cand))
278
+ continue
279
+ cand2 = (base / p).resolve()
280
+ if cand2.is_file():
281
+ shots.append(str(cand2))
282
+ continue
283
+ if p.is_file():
284
+ shots.append(str(p.resolve()))
285
+ else:
286
+ shots.append(str(p))
287
+ return shots
288
+
289
+
290
+ def _token_usage_from(raw: Any) -> TokenUsage:
291
+ if isinstance(raw, TokenUsage):
292
+ return raw
293
+ if not isinstance(raw, dict):
294
+ return TokenUsage()
295
+ return TokenUsage(
296
+ prompt_tokens=int(raw.get("prompt_tokens") or 0),
297
+ completion_tokens=int(raw.get("completion_tokens") or 0),
298
+ total_tokens=int(raw.get("total_tokens") or 0),
299
+ cost=float(raw.get("cost") or 0.0),
300
+ )
301
+
302
+
303
+ def case_record_from_report_case(
304
+ case: ReportCase,
305
+ *,
306
+ app_id: str = "",
307
+ mode: str = "ai",
308
+ tags: list[str] | None = None,
309
+ heal_attempts: int = 0,
310
+ source_case: str | None = None,
311
+ ) -> CaseRecord:
312
+ status = "passed" if case.success else "failed"
313
+ if case.synthesis_only and case.success:
314
+ status = "passed"
315
+ duration_ms = int(max(0.0, float(case.duration_s or 0.0)) * 1000)
316
+ started = case.started_at or utc_now_iso()
317
+ phases: list[PhaseStatus] = []
318
+ if case.logs:
319
+ phases.append(
320
+ PhaseStatus(
321
+ name="run",
322
+ status="success" if case.success else "failed",
323
+ detail=case.summary or ("passed" if case.success else "failed"),
324
+ duration_ms=duration_ms,
325
+ )
326
+ )
327
+ steps: list[StepRecord] = []
328
+ for i, line in enumerate(case.logs or [], start=1):
329
+ steps.append(
330
+ StepRecord(
331
+ index=i,
332
+ action="log",
333
+ status="pass" if case.success else "info",
334
+ raw=line,
335
+ detail=line,
336
+ )
337
+ )
338
+ if not phases:
339
+ phases.append(
340
+ PhaseStatus(
341
+ name="run",
342
+ status="success" if case.success else "failed",
343
+ detail=case.summary or ("passed" if case.success else "failed"),
344
+ duration_ms=duration_ms,
345
+ )
346
+ )
347
+ shots = _abs_shots(case)
348
+ artifacts: dict[str, Any] = {}
349
+ if shots:
350
+ artifacts["screenshot"] = shots[0]
351
+ artifacts["screenshots"] = shots
352
+ if case.video_url:
353
+ artifacts["video_path"] = case.video_url
354
+ if case.artifact_dir:
355
+ artifacts["artifact_dir"] = case.artifact_dir
356
+ if case.flow_path:
357
+ artifacts["flow"] = case.flow_path
358
+ if case.provider:
359
+ artifacts["provider"] = case.provider
360
+ if case.device_id:
361
+ artifacts["device_id"] = case.device_id
362
+ if case.dashboard_url:
363
+ artifacts["dashboard_url"] = case.dashboard_url
364
+ if case.build_id:
365
+ artifacts["build_id"] = case.build_id
366
+ tag_list = list(tags or [])
367
+ if case.provider and case.provider not in tag_list:
368
+ tag_list.append(case.provider)
369
+ if case.platform and case.platform not in tag_list:
370
+ tag_list.append(case.platform)
371
+ errors = [e for e in (case.error, case.summary) if e and not case.success]
372
+ return CaseRecord(
373
+ id=case.name,
374
+ name=case.name,
375
+ status=status,
376
+ mode=mode,
377
+ url=(app_id or "").strip() or (case.platform or ""),
378
+ tags=tag_list,
379
+ title=(case.task or case.name).split("\n", 1)[0][:160],
380
+ started_at=started,
381
+ finished_at=utc_now_iso(),
382
+ duration_ms=duration_ms,
383
+ phases=phases,
384
+ steps=steps,
385
+ explore_usage=_token_usage_from(case.explore_usage),
386
+ codegen_usage=_token_usage_from(case.codegen_usage),
387
+ files_generated=[case.flow_path] if case.flow_path else [],
388
+ heal_attempts=max(0, int(heal_attempts)),
389
+ failure_output=(case.stderr or case.stdout or case.error or "")[:20000],
390
+ errors=errors,
391
+ artifacts=artifacts,
392
+ source_case=source_case,
393
+ prompt=case.task or None,
394
+ )
395
+
396
+
397
+ def build_insights(cases: list[CaseRecord], summary: PackSummary) -> list[Insight]:
398
+ insights: list[Insight] = []
399
+ if summary.total == 0:
400
+ insights.append(
401
+ Insight(
402
+ severity="warn",
403
+ title="No cases in pack",
404
+ body="Run mobiflow against one or more cases, then regenerate the report.",
405
+ )
406
+ )
407
+ return insights
408
+ if summary.failed or summary.error:
409
+ insights.append(
410
+ Insight(
411
+ severity="fail",
412
+ title=f"{summary.failed + summary.error} failing case(s)",
413
+ body="Open Failures for stderr, screenshots, and heal attempts.",
414
+ )
415
+ )
416
+ else:
417
+ insights.append(
418
+ Insight(
419
+ severity="pass",
420
+ title=f"Pass rate {summary.pass_rate:.0f}%",
421
+ body="All cases passed in this pack.",
422
+ )
423
+ )
424
+ healed = [c for c in cases if c.heal_attempts > 0]
425
+ if healed:
426
+ insights.append(
427
+ Insight(
428
+ severity="warn",
429
+ title=f"{len(healed)} case(s) needed heal/retry",
430
+ body="Review Flaky Tests for instability signals.",
431
+ )
432
+ )
433
+ return insights
434
+
435
+
436
+ def build_pack(
437
+ cases: list[CaseRecord],
438
+ *,
439
+ title: str = "MobiFlow Execution Report",
440
+ env: EnvInfo | None = None,
441
+ pack_id: str | None = None,
442
+ ) -> ReportPack:
443
+ passed = sum(1 for c in cases if c.status == "passed")
444
+ failed = sum(1 for c in cases if c.status == "failed")
445
+ skipped = sum(1 for c in cases if c.status == "skipped")
446
+ error = sum(1 for c in cases if c.status == "error")
447
+ total = len(cases)
448
+ duration_ms = sum(int(c.duration_ms or 0) for c in cases)
449
+ usage = TokenUsage()
450
+ for c in cases:
451
+ usage = usage.merged(c.total_usage)
452
+ pass_rate = (100.0 * passed / total) if total else 0.0
453
+ summary = PackSummary(
454
+ total=total,
455
+ passed=passed,
456
+ failed=failed,
457
+ skipped=skipped,
458
+ error=error,
459
+ duration_ms=duration_ms,
460
+ prompt_tokens=usage.prompt_tokens,
461
+ completion_tokens=usage.completion_tokens,
462
+ total_tokens=usage.total_tokens,
463
+ cost=usage.cost,
464
+ pass_rate=pass_rate,
465
+ )
466
+ env_info = env or EnvInfo(
467
+ python=platform.python_version(),
468
+ platform=f"{platform.system()} {platform.release()} ({platform.machine()})",
469
+ stack_tool="maestro",
470
+ stack_runner="maestro",
471
+ )
472
+ if not env_info.python:
473
+ env_info.python = platform.python_version()
474
+ pack = ReportPack(
475
+ id=pack_id or uuid.uuid4().hex[:8],
476
+ title=title,
477
+ generated_at=utc_now_iso(),
478
+ env=env_info,
479
+ summary=summary,
480
+ cases=list(cases),
481
+ trends=[],
482
+ insights=build_insights(cases, summary),
483
+ )
484
+ return pack
485
+
486
+
487
+ def _detect_maestro_version() -> str:
488
+ import re
489
+ import shutil
490
+ import subprocess
491
+
492
+ binary = shutil.which("maestro")
493
+ if not binary:
494
+ return ""
495
+ try:
496
+ proc = subprocess.run(
497
+ [binary, "--version"],
498
+ capture_output=True,
499
+ text=True,
500
+ timeout=8,
501
+ check=False,
502
+ )
503
+ text = (proc.stdout or proc.stderr or "").strip()
504
+ if not text:
505
+ return ""
506
+ first = text.splitlines()[0].strip()
507
+ # Ignore JVM / install errors masquerading as version output
508
+ if re.search(r"(?i)unable to locate|error|exception|not found", first):
509
+ return ""
510
+ if not re.search(r"\d+\.\d+", first):
511
+ return ""
512
+ return first[:120]
513
+ except (OSError, subprocess.SubprocessError):
514
+ return ""
515
+
516
+
517
+ def env_from_config(
518
+ cfg: Any | None = None,
519
+ *,
520
+ device_label: str = "",
521
+ app_id: str = "",
522
+ mobile_platform: str = "",
523
+ device_provider: str = "",
524
+ ) -> EnvInfo:
525
+ from importlib.metadata import PackageNotFoundError, version
526
+
527
+ try:
528
+ ver = version("mobiflow")
529
+ except PackageNotFoundError:
530
+ ver = "0.2.0"
531
+ llm_provider = ""
532
+ lang = "yaml+js"
533
+ repo = ""
534
+ if cfg is not None:
535
+ try:
536
+ repo = str(cfg.repo_path())
537
+ lang = str(getattr(cfg.stack, "language", lang) or lang)
538
+ try:
539
+ prof = cfg.codegen_profile()
540
+ llm_provider = str(getattr(prof, "provider", "") or "")
541
+ except Exception: # noqa: BLE001
542
+ pass
543
+ app_id = app_id or str(getattr(cfg.device, "app_id", "") or "")
544
+ device_label = device_label or str(
545
+ getattr(cfg.device, "device_id", "") or ""
546
+ )
547
+ mobile_platform = mobile_platform or str(
548
+ getattr(cfg.device, "platform", "") or ""
549
+ )
550
+ device_provider = device_provider or str(
551
+ getattr(cfg.device, "provider", "") or "local"
552
+ )
553
+ except Exception: # noqa: BLE001
554
+ pass
555
+ return EnvInfo(
556
+ python=platform.python_version(),
557
+ platform=f"{platform.system()} {platform.release()} ({platform.machine()})",
558
+ mobiflow_version=ver,
559
+ maestro=_detect_maestro_version(),
560
+ llm_provider=llm_provider,
561
+ stack_tool="maestro",
562
+ stack_language=lang,
563
+ stack_runner="maestro",
564
+ repo_path=repo,
565
+ device=device_label,
566
+ app_id=app_id,
567
+ mobile_platform=mobile_platform,
568
+ device_provider=device_provider or "local",
569
+ browser=device_label,
570
+ url=app_id,
571
+ )
572
+
573
+
574
+ def write_rich_reports(
575
+ cases: list[ReportCase],
576
+ report_dir: Path,
577
+ *,
578
+ title: str = "MobiFlow Execution Report",
579
+ cfg: Any | None = None,
580
+ app_id: str = "",
581
+ ) -> dict[str, str]:
582
+ """Write pack.json + SPA index.html into report_dir."""
583
+ records = [
584
+ case_record_from_report_case(
585
+ c,
586
+ app_id=app_id,
587
+ mode="scripted" if c.synthesis_only else "ai",
588
+ )
589
+ for c in cases
590
+ ]
591
+ device = next((c.device_id for c in cases if c.device_id), "")
592
+ mobile_platform = next((c.platform for c in cases if c.platform), "")
593
+ device_provider = next((c.provider for c in cases if c.provider), "local")
594
+ if not app_id:
595
+ # Prefer real app ids over platform labels stuffed into case.url
596
+ for rec in records:
597
+ if rec.url and rec.url.lower() not in {"ios", "android"}:
598
+ app_id = rec.url
599
+ break
600
+ pack = build_pack(
601
+ records,
602
+ title=title,
603
+ env=env_from_config(
604
+ cfg,
605
+ device_label=device,
606
+ app_id=app_id,
607
+ mobile_platform=mobile_platform,
608
+ device_provider=device_provider,
609
+ ),
610
+ )
611
+ report_dir = Path(report_dir)
612
+ report_dir.mkdir(parents=True, exist_ok=True)
613
+ html_path = write_pack_html(pack, report_dir / "index.html")
614
+ json_path = write_pack_json(pack, report_dir / "pack.json")
615
+ # Also mirror as report.html for existing links
616
+ (report_dir / "report.html").write_text(html_path.read_text(encoding="utf-8"), encoding="utf-8")
617
+ return {"html": str(html_path), "json": str(json_path), "report": str(report_dir / "report.html")}
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
@@ -0,0 +1,24 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg">
2
+ <symbol id="bluesky-icon" viewBox="0 0 16 17">
3
+ <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
4
+ <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
5
+ </symbol>
6
+ <symbol id="discord-icon" viewBox="0 0 20 19">
7
+ <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
8
+ </symbol>
9
+ <symbol id="documentation-icon" viewBox="0 0 21 20">
10
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
11
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
12
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
13
+ </symbol>
14
+ <symbol id="github-icon" viewBox="0 0 19 19">
15
+ <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
16
+ </symbol>
17
+ <symbol id="social-icon" viewBox="0 0 20 20">
18
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
19
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
20
+ </symbol>
21
+ <symbol id="x-icon" viewBox="0 0 19 19">
22
+ <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
23
+ </symbol>
24
+ </svg>