looper-dashboard 0.1.0__tar.gz

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 (26) hide show
  1. looper_dashboard-0.1.0/PKG-INFO +198 -0
  2. looper_dashboard-0.1.0/README.md +187 -0
  3. looper_dashboard-0.1.0/looper_dashboard/__init__.py +3 -0
  4. looper_dashboard-0.1.0/looper_dashboard/ai_analyzer.py +221 -0
  5. looper_dashboard-0.1.0/looper_dashboard/cli.py +282 -0
  6. looper_dashboard-0.1.0/looper_dashboard/karate_generator.py +279 -0
  7. looper_dashboard-0.1.0/looper_dashboard/karate_ops.py +286 -0
  8. looper_dashboard-0.1.0/looper_dashboard/looper_auth.py +186 -0
  9. looper_dashboard-0.1.0/looper_dashboard/looper_core.py +196 -0
  10. looper_dashboard-0.1.0/looper_dashboard/monocart_generator.py +251 -0
  11. looper_dashboard-0.1.0/looper_dashboard/monocart_ops.py +287 -0
  12. looper_dashboard-0.1.0/looper_dashboard/orchestrator.py +263 -0
  13. looper_dashboard-0.1.0/looper_dashboard/playwright_generator.py +203 -0
  14. looper_dashboard-0.1.0/looper_dashboard/playwright_ops.py +270 -0
  15. looper_dashboard-0.1.0/looper_dashboard/templates/__init__.py +0 -0
  16. looper_dashboard-0.1.0/looper_dashboard/templates/karate.html +575 -0
  17. looper_dashboard-0.1.0/looper_dashboard/templates/monocart.html +578 -0
  18. looper_dashboard-0.1.0/looper_dashboard/templates/playwright.html +577 -0
  19. looper_dashboard-0.1.0/looper_dashboard.egg-info/PKG-INFO +198 -0
  20. looper_dashboard-0.1.0/looper_dashboard.egg-info/SOURCES.txt +24 -0
  21. looper_dashboard-0.1.0/looper_dashboard.egg-info/dependency_links.txt +1 -0
  22. looper_dashboard-0.1.0/looper_dashboard.egg-info/entry_points.txt +2 -0
  23. looper_dashboard-0.1.0/looper_dashboard.egg-info/requires.txt +2 -0
  24. looper_dashboard-0.1.0/looper_dashboard.egg-info/top_level.txt +1 -0
  25. looper_dashboard-0.1.0/pyproject.toml +26 -0
  26. looper_dashboard-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: looper-dashboard
3
+ Version: 0.1.0
4
+ Summary: Generate daily CI dashboards for any QE repo on LooperPro (Karate, Playwright, Monocart)
5
+ License: MIT
6
+ Keywords: karate,playwright,monocart,looperpro,ci,dashboard
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: requests>=2.31
11
+
12
+ # looper-dashboard
13
+
14
+ Generate daily CI dashboards for any QE repository on the [LooperPro](https://looperpro.prod.walmart.com) CI platform.
15
+
16
+ Supports three test suite types:
17
+
18
+ | Suite | Report format | Default history |
19
+ |---|---|---|
20
+ | `karate` | `karate-summary-json.txt` + per-feature detail | 14 days |
21
+ | `playwright` | `pw-summary-report.json` | 14 days |
22
+ | `playwright-monocart` | `monocart-report/index.html` (zlib-compressed) | 30 days |
23
+
24
+ ---
25
+
26
+ ## Requirements
27
+
28
+ | Requirement | Why |
29
+ |---|---|
30
+ | Python 3.11+ | Runtime |
31
+ | `mcp-cli` (optional) | Auto-refreshes LooperPro auth tokens |
32
+ | `code-puppy` (optional) | Powers the AI failure analysis step |
33
+
34
+ Authentication is read from `~/.mcp-cli/tokens.json` or `~/.code_puppy/puppy.cfg` automatically — no extra config needed.
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install looper-dashboard
42
+ ```
43
+
44
+ Or from the repo:
45
+
46
+ ```bash
47
+ pip install ./looper-dashboard
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Quick Start
53
+
54
+ ### 1. Scaffold a config
55
+
56
+ ```bash
57
+ # Creates daily_flows.yml in the current directory
58
+ looper-dashboard --init karate
59
+ looper-dashboard --init playwright
60
+ looper-dashboard --init playwright-monocart
61
+ ```
62
+
63
+ Edit the generated file to add your LooperPro job names and flow names.
64
+
65
+ ### 2. Generate the dashboard
66
+
67
+ ```bash
68
+ looper-dashboard --suite karate --config daily_flows.yml --open
69
+ looper-dashboard --suite playwright --config daily_flows.yml --open
70
+ looper-dashboard --suite playwright-monocart --config daily_flows.yml --open
71
+ ```
72
+
73
+ ### 3. Override the repo SCM URL
74
+
75
+ ```bash
76
+ looper-dashboard --suite karate \
77
+ --config daily_flows.yml \
78
+ --scm-url https://gecgithub01.walmart.com/my-org/my-repo.git \
79
+ --open
80
+ ```
81
+
82
+ ---
83
+
84
+ ## `daily_flows.yml` format
85
+
86
+ ### Karate / Playwright
87
+
88
+ ```yaml
89
+ # SCM URL of the repository being monitored
90
+ scm_url: https://gecgithub01.walmart.com/my-org/my-repo.git
91
+
92
+ jobs:
93
+
94
+ # LooperPro job name → list of flow names to monitor
95
+ my-org/my-repo-tests:
96
+ - my-flow-dev
97
+ - my-flow-stage
98
+ - my-flow-prod
99
+
100
+ my-org/my-repo-health-checks:
101
+ - health-dev
102
+ - health-stage
103
+ ```
104
+
105
+ ### Playwright-Monocart (with explicit job_ids)
106
+
107
+ Some repos' jobs cannot be discovered by SCM URL in LooperPro. Add a `job_ids` section with the UUIDs from the LooperPro UI:
108
+
109
+ ```yaml
110
+ scm_url: https://gecgithub01.walmart.com/my-org/my-repo.git
111
+
112
+ # Explicit job IDs (look these up in the LooperPro UI)
113
+ job_ids:
114
+ my-monocart-job: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
115
+
116
+ jobs:
117
+
118
+ my-monocart-job:
119
+ - e2e-flow-dev
120
+ - e2e-flow-stage
121
+ ```
122
+
123
+ ---
124
+
125
+ ## CLI Reference
126
+
127
+ ```
128
+ looper-dashboard [OPTIONS]
129
+
130
+ Options:
131
+ --suite SUITE Test suite type: karate, playwright, playwright-monocart [required]
132
+ --config PATH Path to daily_flows.yml [required]
133
+ --scm-url URL Override scm_url from daily_flows.yml
134
+ --output PATH Output HTML file path (default varies by suite)
135
+ --days N Days of build history to fetch (default: 14 or 30)
136
+ --open Auto-open dashboard in browser when done
137
+ --no-ai Skip the AI failure analysis step
138
+ --no-details Skip fetching test summary details (faster, status only)
139
+ --save-failures PATH Path to save failures JSON
140
+ --analysis-json PATH Use a pre-computed AI analysis JSON instead of running live AI
141
+ --init SUITE Scaffold a starter daily_flows.yml and exit
142
+
143
+ -h, --help Show help and exit
144
+ ```
145
+
146
+ ---
147
+
148
+ ## AI Analysis
149
+
150
+ When `--no-ai` is not set, the orchestrator:
151
+
152
+ 1. Saves a `*-failures.json` summary of all failing flows
153
+ 2. Invokes `code-puppy` to classify each failure as one of:
154
+ - `service_regression` — likely a code regression in the app
155
+ - `environment_issue` — infra/network/auth failure
156
+ - `test_issue` — stale selector or bad test data
157
+ - `flaky` — intermittent, no clear pattern
158
+ 3. Regenerates the dashboard HTML with an AI analysis panel embedded
159
+
160
+ If `code-puppy` is not installed, the AI step is skipped gracefully.
161
+
162
+ ---
163
+
164
+ ## Publishing
165
+
166
+ ### To public PyPI
167
+
168
+ ```bash
169
+ cd looper-dashboard
170
+ pip install hatch
171
+ hatch build # creates dist/looper_dashboard-0.1.0.tar.gz and .whl
172
+ hatch publish # prompts for PyPI API token
173
+ ```
174
+
175
+ ### To Walmart internal PyPI registry
176
+
177
+ Add the registry to `~/.config/hatch/config.toml`:
178
+
179
+ ```toml
180
+ [publish.index.repos.internal]
181
+ url = "https://your-internal-pypi-registry/simple/"
182
+ ```
183
+
184
+ Then:
185
+
186
+ ```bash
187
+ hatch publish --repo internal
188
+ ```
189
+
190
+ ---
191
+
192
+ ## Onboarding a new repo
193
+
194
+ 1. Create a `daily_flows.yml` for the target repo (use `--init` to scaffold)
195
+ 2. Add job and flow names (find them in LooperPro or via `mcp-cli`)
196
+ 3. Run `looper-dashboard --suite <type> --config daily_flows.yml --open`
197
+
198
+ No code changes required — the package is entirely configuration-driven.
@@ -0,0 +1,187 @@
1
+ # looper-dashboard
2
+
3
+ Generate daily CI dashboards for any QE repository on the [LooperPro](https://looperpro.prod.walmart.com) CI platform.
4
+
5
+ Supports three test suite types:
6
+
7
+ | Suite | Report format | Default history |
8
+ |---|---|---|
9
+ | `karate` | `karate-summary-json.txt` + per-feature detail | 14 days |
10
+ | `playwright` | `pw-summary-report.json` | 14 days |
11
+ | `playwright-monocart` | `monocart-report/index.html` (zlib-compressed) | 30 days |
12
+
13
+ ---
14
+
15
+ ## Requirements
16
+
17
+ | Requirement | Why |
18
+ |---|---|
19
+ | Python 3.11+ | Runtime |
20
+ | `mcp-cli` (optional) | Auto-refreshes LooperPro auth tokens |
21
+ | `code-puppy` (optional) | Powers the AI failure analysis step |
22
+
23
+ Authentication is read from `~/.mcp-cli/tokens.json` or `~/.code_puppy/puppy.cfg` automatically — no extra config needed.
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install looper-dashboard
31
+ ```
32
+
33
+ Or from the repo:
34
+
35
+ ```bash
36
+ pip install ./looper-dashboard
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Quick Start
42
+
43
+ ### 1. Scaffold a config
44
+
45
+ ```bash
46
+ # Creates daily_flows.yml in the current directory
47
+ looper-dashboard --init karate
48
+ looper-dashboard --init playwright
49
+ looper-dashboard --init playwright-monocart
50
+ ```
51
+
52
+ Edit the generated file to add your LooperPro job names and flow names.
53
+
54
+ ### 2. Generate the dashboard
55
+
56
+ ```bash
57
+ looper-dashboard --suite karate --config daily_flows.yml --open
58
+ looper-dashboard --suite playwright --config daily_flows.yml --open
59
+ looper-dashboard --suite playwright-monocart --config daily_flows.yml --open
60
+ ```
61
+
62
+ ### 3. Override the repo SCM URL
63
+
64
+ ```bash
65
+ looper-dashboard --suite karate \
66
+ --config daily_flows.yml \
67
+ --scm-url https://gecgithub01.walmart.com/my-org/my-repo.git \
68
+ --open
69
+ ```
70
+
71
+ ---
72
+
73
+ ## `daily_flows.yml` format
74
+
75
+ ### Karate / Playwright
76
+
77
+ ```yaml
78
+ # SCM URL of the repository being monitored
79
+ scm_url: https://gecgithub01.walmart.com/my-org/my-repo.git
80
+
81
+ jobs:
82
+
83
+ # LooperPro job name → list of flow names to monitor
84
+ my-org/my-repo-tests:
85
+ - my-flow-dev
86
+ - my-flow-stage
87
+ - my-flow-prod
88
+
89
+ my-org/my-repo-health-checks:
90
+ - health-dev
91
+ - health-stage
92
+ ```
93
+
94
+ ### Playwright-Monocart (with explicit job_ids)
95
+
96
+ Some repos' jobs cannot be discovered by SCM URL in LooperPro. Add a `job_ids` section with the UUIDs from the LooperPro UI:
97
+
98
+ ```yaml
99
+ scm_url: https://gecgithub01.walmart.com/my-org/my-repo.git
100
+
101
+ # Explicit job IDs (look these up in the LooperPro UI)
102
+ job_ids:
103
+ my-monocart-job: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
104
+
105
+ jobs:
106
+
107
+ my-monocart-job:
108
+ - e2e-flow-dev
109
+ - e2e-flow-stage
110
+ ```
111
+
112
+ ---
113
+
114
+ ## CLI Reference
115
+
116
+ ```
117
+ looper-dashboard [OPTIONS]
118
+
119
+ Options:
120
+ --suite SUITE Test suite type: karate, playwright, playwright-monocart [required]
121
+ --config PATH Path to daily_flows.yml [required]
122
+ --scm-url URL Override scm_url from daily_flows.yml
123
+ --output PATH Output HTML file path (default varies by suite)
124
+ --days N Days of build history to fetch (default: 14 or 30)
125
+ --open Auto-open dashboard in browser when done
126
+ --no-ai Skip the AI failure analysis step
127
+ --no-details Skip fetching test summary details (faster, status only)
128
+ --save-failures PATH Path to save failures JSON
129
+ --analysis-json PATH Use a pre-computed AI analysis JSON instead of running live AI
130
+ --init SUITE Scaffold a starter daily_flows.yml and exit
131
+
132
+ -h, --help Show help and exit
133
+ ```
134
+
135
+ ---
136
+
137
+ ## AI Analysis
138
+
139
+ When `--no-ai` is not set, the orchestrator:
140
+
141
+ 1. Saves a `*-failures.json` summary of all failing flows
142
+ 2. Invokes `code-puppy` to classify each failure as one of:
143
+ - `service_regression` — likely a code regression in the app
144
+ - `environment_issue` — infra/network/auth failure
145
+ - `test_issue` — stale selector or bad test data
146
+ - `flaky` — intermittent, no clear pattern
147
+ 3. Regenerates the dashboard HTML with an AI analysis panel embedded
148
+
149
+ If `code-puppy` is not installed, the AI step is skipped gracefully.
150
+
151
+ ---
152
+
153
+ ## Publishing
154
+
155
+ ### To public PyPI
156
+
157
+ ```bash
158
+ cd looper-dashboard
159
+ pip install hatch
160
+ hatch build # creates dist/looper_dashboard-0.1.0.tar.gz and .whl
161
+ hatch publish # prompts for PyPI API token
162
+ ```
163
+
164
+ ### To Walmart internal PyPI registry
165
+
166
+ Add the registry to `~/.config/hatch/config.toml`:
167
+
168
+ ```toml
169
+ [publish.index.repos.internal]
170
+ url = "https://your-internal-pypi-registry/simple/"
171
+ ```
172
+
173
+ Then:
174
+
175
+ ```bash
176
+ hatch publish --repo internal
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Onboarding a new repo
182
+
183
+ 1. Create a `daily_flows.yml` for the target repo (use `--init` to scaffold)
184
+ 2. Add job and flow names (find them in LooperPro or via `mcp-cli`)
185
+ 3. Run `looper-dashboard --suite <type> --config daily_flows.yml --open`
186
+
187
+ No code changes required — the package is entirely configuration-driven.
@@ -0,0 +1,3 @@
1
+ """looper-dashboard — daily CI dashboards for any QE repo on LooperPro."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,221 @@
1
+ """AI failure analyzer for Playwright and Monocart dashboards.
2
+
3
+ Invokes playwright-monocart-helper via the code-puppy CLI to batch-analyze
4
+ all failing/flaky flows and return structured classifications.
5
+
6
+ Usage (standalone):
7
+ python3 -m looper_dashboard.ai_analyzer
8
+ python3 -m looper_dashboard.ai_analyzer --failures failures.json --output analysis.json
9
+ """
10
+ import base64
11
+ import json
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Constants
19
+ # ---------------------------------------------------------------------------
20
+
21
+ _VENV_BIN = Path.home() / ".code-puppy-venv" / "bin" / "code-puppy"
22
+ AGENT = "playwright-monocart-helper"
23
+ TIMEOUT = 240 # seconds
24
+ MAX_TITLES = 15 # max test titles per flow to keep token usage reasonable
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # ANSI / OSC stripping
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def _strip_ansi(text: str) -> str:
32
+ """Remove ANSI/VT escape sequences (including OSC) from text."""
33
+ text = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', text)
34
+ text = re.sub(r'\x1b\].*?(?:\x07|\x1b\\)', '', text, flags=re.DOTALL)
35
+ text = re.sub(r'\x1b.', '', text)
36
+ return text
37
+
38
+
39
+ def _extract_json_from_osc52(text: str) -> list | None:
40
+ """Extract and decode a base64 payload from an OSC 52 clipboard sequence."""
41
+ m = re.search(r']\s*52\s*;\s*c\s*;\s*([A-Za-z0-9+/=]+)', text)
42
+ if not m:
43
+ return None
44
+ try:
45
+ decoded = base64.b64decode(m.group(1)).decode("utf-8")
46
+ return json.loads(decoded)
47
+ except Exception: # noqa: BLE001
48
+ return None
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Prompt construction
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def _build_prompt(failures: list[dict]) -> str:
56
+ trimmed = [
57
+ {
58
+ **f,
59
+ "failedTests": (f.get("failedTests") or [])[:MAX_TITLES],
60
+ "flakyTests": (f.get("flakyTests") or [])[:MAX_TITLES],
61
+ }
62
+ for f in failures
63
+ ]
64
+
65
+ return f"""You are analyzing Playwright test failures from a CI/CD pipeline.
66
+
67
+ For each failing flow below, classify the root cause and provide actionable guidance.
68
+
69
+ Failures:
70
+ {json.dumps(trimmed, indent=2)}
71
+
72
+ Respond with ONLY a raw JSON array (no markdown fences, no explanation).
73
+ Each element must have exactly these fields:
74
+ "flow" - (string) the flowName from the input
75
+ "job" - (string) the job short name from the input
76
+ "classification" - one of: "service_regression", "environment_issue", "test_issue", "flaky"
77
+ "severity" - one of: "high", "medium", "low"
78
+ "summary" - (string <=120 chars) what is failing and likely why, inferred from test names
79
+ "recommendation" - (string <=200 chars) concrete next step to fix or investigate
80
+
81
+ Classification guide:
82
+ service_regression - feature tests failing; likely a code regression in the app
83
+ environment_issue - infra/network/auth failures or env-specific issues
84
+ test_issue - stale selectors, bad test data, or a test code bug
85
+ flaky - intermittent with no clear pattern
86
+
87
+ Start your response with [ and end with ].
88
+ """
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # JSON extraction
93
+ # ---------------------------------------------------------------------------
94
+
95
+ def _extract_json(text: str) -> list | None:
96
+ """Robustly extract a JSON array from code-puppy output."""
97
+ osc = _extract_json_from_osc52(text)
98
+ if osc is not None:
99
+ return osc
100
+
101
+ stripped = _strip_ansi(text).strip()
102
+
103
+ if stripped.startswith("["):
104
+ try:
105
+ return json.loads(stripped)
106
+ except json.JSONDecodeError:
107
+ pass
108
+
109
+ m = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", stripped, re.DOTALL)
110
+ if m:
111
+ try:
112
+ return json.loads(m.group(1))
113
+ except json.JSONDecodeError:
114
+ pass
115
+
116
+ m = re.search(r"(\[.*\])", stripped, re.DOTALL)
117
+ if m:
118
+ try:
119
+ return json.loads(m.group(1))
120
+ except json.JSONDecodeError:
121
+ pass
122
+
123
+ return None
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Public API
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def analyze_failures(
131
+ failures: list[dict],
132
+ code_puppy_bin: str | None = None,
133
+ ) -> list[dict]:
134
+ """Invoke playwright-monocart-helper to classify each failing/flaky flow.
135
+
136
+ Args:
137
+ failures: List of failure dicts from save_failure_summary.
138
+ code_puppy_bin: Override path to code-puppy binary.
139
+
140
+ Returns:
141
+ List of analysis dicts, or [] on any failure (never raises).
142
+ """
143
+ if not failures:
144
+ return []
145
+
146
+ bin_path = Path(code_puppy_bin or _VENV_BIN)
147
+ if not bin_path.exists():
148
+ print(f"Warning: code-puppy not found at {bin_path}", file=sys.stderr)
149
+ return []
150
+
151
+ prompt = _build_prompt(failures)
152
+ n = len(failures)
153
+ print(f"Invoking {AGENT} to analyze {n} failing flow{'s' if n != 1 else ''}...", file=sys.stderr)
154
+
155
+ try:
156
+ result = subprocess.run(
157
+ [str(bin_path), "--prompt", prompt, "--agent", AGENT],
158
+ capture_output=True,
159
+ text=True,
160
+ timeout=TIMEOUT,
161
+ )
162
+ except subprocess.TimeoutExpired:
163
+ print(f"Warning: AI analysis timed out after {TIMEOUT}s.", file=sys.stderr)
164
+ return []
165
+ except Exception as exc: # noqa: BLE001
166
+ print(f"Warning: AI analysis subprocess error: {exc}", file=sys.stderr)
167
+ return []
168
+
169
+ raw = result.stdout or ""
170
+ analysis = _extract_json(raw)
171
+
172
+ if analysis is None and result.stderr:
173
+ analysis = _extract_json(result.stderr)
174
+
175
+ if analysis is None:
176
+ print("Warning: Could not parse JSON from AI output.", file=sys.stderr)
177
+ tail = (raw or result.stderr or "").strip().splitlines()
178
+ for line in tail[-8:]:
179
+ print(f" | {line}", file=sys.stderr)
180
+ return []
181
+
182
+ print(f"AI analysis complete — {len(analysis)} flow{'s' if len(analysis) != 1 else ''} classified.", file=sys.stderr)
183
+ return analysis
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # CLI (standalone use)
188
+ # ---------------------------------------------------------------------------
189
+
190
+ def main() -> None:
191
+ import argparse
192
+
193
+ parser = argparse.ArgumentParser(
194
+ description="Analyze Playwright/Monocart failures with playwright-monocart-helper AI",
195
+ )
196
+ parser.add_argument("--failures", default="failures.json", help="Path to failures JSON")
197
+ parser.add_argument("--output", default="ai-analysis.json", help="Output path for analysis JSON")
198
+ parser.add_argument("--code-puppy-bin", default=None, help=f"Override path to code-puppy (default: {_VENV_BIN})")
199
+ args = parser.parse_args()
200
+
201
+ failures_path = Path(args.failures)
202
+ if not failures_path.exists():
203
+ print(f"Failures file not found: {failures_path}", file=sys.stderr)
204
+ sys.exit(1)
205
+
206
+ failures = json.loads(failures_path.read_text())
207
+ if not failures:
208
+ print("No failures to analyze.", file=sys.stderr)
209
+ sys.exit(0)
210
+
211
+ analysis = analyze_failures(failures, code_puppy_bin=args.code_puppy_bin)
212
+ if not analysis:
213
+ sys.exit(1)
214
+
215
+ output_path = Path(args.output)
216
+ output_path.write_text(json.dumps(analysis, indent=2))
217
+ print(f"Analysis saved to {output_path.resolve()}", file=sys.stderr)
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()