slopstopper-cli 0.3.0__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 (40) hide show
  1. slopstopper/__init__.py +1 -0
  2. slopstopper/__main__.py +4 -0
  3. slopstopper/checks/__init__.py +41 -0
  4. slopstopper/checks/accessibility.py +133 -0
  5. slopstopper/checks/broken_links.py +128 -0
  6. slopstopper/checks/complexity.py +237 -0
  7. slopstopper/checks/csp_exceptions.py +329 -0
  8. slopstopper/checks/cwv.py +269 -0
  9. slopstopper/checks/dast.py +331 -0
  10. slopstopper/checks/dependencies.py +195 -0
  11. slopstopper/checks/docs_accuracy.py +298 -0
  12. slopstopper/checks/docs_size.py +245 -0
  13. slopstopper/checks/docs_structure.py +258 -0
  14. slopstopper/checks/entry_files.py +190 -0
  15. slopstopper/checks/sast.py +227 -0
  16. slopstopper/checks/secrets.py +148 -0
  17. slopstopper/checks/seo.py +515 -0
  18. slopstopper/checks/smoke.py +101 -0
  19. slopstopper/cli.py +670 -0
  20. slopstopper/config.py +193 -0
  21. slopstopper/dast_gate.py +257 -0
  22. slopstopper/data/lighthouserc.json +20 -0
  23. slopstopper/data/lighthouserc.prod.json +24 -0
  24. slopstopper/data/playwright.config.js +37 -0
  25. slopstopper/data/server.js +208 -0
  26. slopstopper/data/tests/accessibility.spec.ts +87 -0
  27. slopstopper/data/tests/broken-links.spec.ts +54 -0
  28. slopstopper/data/tests/smoke.spec.ts +77 -0
  29. slopstopper/discovery.py +366 -0
  30. slopstopper/emit.py +258 -0
  31. slopstopper/headers_adapters/__init__.py +54 -0
  32. slopstopper/headers_adapters/cloudflare_adapter.py +55 -0
  33. slopstopper/headers_adapters/json_adapter.py +29 -0
  34. slopstopper/output.py +127 -0
  35. slopstopper/templates.py +134 -0
  36. slopstopper_cli-0.3.0.dist-info/METADATA +123 -0
  37. slopstopper_cli-0.3.0.dist-info/RECORD +40 -0
  38. slopstopper_cli-0.3.0.dist-info/WHEEL +4 -0
  39. slopstopper_cli-0.3.0.dist-info/entry_points.txt +2 -0
  40. slopstopper_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,329 @@
1
+ """CSP-Exceptions Drift Detector.
2
+
3
+ Ports .ss/scripts/check-csp-exceptions.py. Enforces that every per-path
4
+ CSP relaxation in the configured header source is documented in
5
+ `docs/security/CSP_EXCEPTIONS.md`, and vice versa.
6
+
7
+ Configuration (.slopstopper.yml):
8
+
9
+ headers:
10
+ source: worker/headers.json # path to header file (set to null to skip)
11
+ format: json # json | cloudflare-text | auto
12
+
13
+ See .slopstopper.yml.example for the canonical schema and supported
14
+ adapters.
15
+
16
+ Exit codes mirror the bash:
17
+ 0 — source and doc agree (or no source configured — graceful skip)
18
+ 1 — drift detected (details in report)
19
+ 2 — required input files missing OR unknown adapter format
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ from slopstopper import config, headers_adapters, output
30
+
31
+ EXCEPTIONS_DOC = Path("docs/security/CSP_EXCEPTIONS.md")
32
+ REPORT_DIR = Path(".ss/reports/csp")
33
+ REPORT_JSON = REPORT_DIR / "csp-exceptions-report.json"
34
+ REPORT_MD = REPORT_DIR / "csp-exceptions-report.md"
35
+
36
+ # Consumed by `slopstopper emit hygiene:csp-exceptions --target pr-comment`.
37
+ # The discriminator substring `🔐 CSP Exceptions` appears in both legacy
38
+ # bot comments (pre-flip JS heading "🔐 CSP Exceptions Check") and the
39
+ # post-flip body (report H1 "🔐 CSP Exceptions Report"), so a single bot
40
+ # comment is reused across the migration. No issue keys: this check fails
41
+ # the workflow on drift via its own exit code, no main-branch issue is
42
+ # created.
43
+ META = {
44
+ "report_path": str(REPORT_MD),
45
+ "comment_discriminator": "🔐 CSP Exceptions",
46
+ }
47
+
48
+ REQUIRED_FIELDS = {
49
+ "Origin allowed",
50
+ "Directives added",
51
+ "Loader SRI",
52
+ "Why",
53
+ "Approved by",
54
+ "Data leaving site",
55
+ "Refresh policy",
56
+ }
57
+
58
+ ORIGIN_RE = re.compile(r"https?://[^\s`'\"\)\],]+")
59
+ HEADING_RE = re.compile(r"^###\s+`?(/[^\s`]+)`?\s*$")
60
+ FIELD_RE = re.compile(r"^-\s*\*\*([^:*]+):\*\*\s*(.*)$")
61
+
62
+
63
+ def _resolve_source() -> tuple[Path | None, str | None, str | None]:
64
+ """Return (path, format_name, skip_reason)."""
65
+ source_str = config.get("headers.source")
66
+ format_name = config.get("headers.format", "auto") or "auto"
67
+ if not source_str:
68
+ return None, None, "no headers.source configured in .slopstopper.yml"
69
+ source_path = Path(str(source_str))
70
+ if not source_path.exists():
71
+ return source_path, format_name, f"configured headers source {source_path} does not exist"
72
+ return source_path, format_name, None
73
+
74
+
75
+ def _extract_csp_origins(csp: str) -> set[str]:
76
+ origins: set[str] = set()
77
+ for directive in csp.split(";"):
78
+ for token in directive.strip().split():
79
+ if token.startswith(("http://", "https://")):
80
+ origins.add(token.rstrip("/"))
81
+ return origins
82
+
83
+
84
+ # ── CSP_EXCEPTIONS.md parser ────────────────────────────────────────
85
+
86
+
87
+ def _new_entry() -> dict:
88
+ return {"origins": set(), "sri": None, "fields_seen": set()}
89
+
90
+
91
+ def _apply_doc_field(entry: dict, field: str, value: str) -> None:
92
+ entry["fields_seen"].add(field)
93
+ if field in {"Origin allowed", "Directives added"}:
94
+ entry["origins"].update(o.rstrip("/") for o in ORIGIN_RE.findall(value))
95
+ elif field == "Loader SRI":
96
+ entry["sri"] = value.split()[0] if value else None
97
+
98
+
99
+ def _flush_doc_entry(out: dict, state: dict) -> None:
100
+ if state["current"] is not None and state["path"] is not None:
101
+ out[state["path"]] = state["current"]
102
+ state["current"] = None
103
+ state["path"] = None
104
+
105
+
106
+ def _handle_doc_line(out: dict, state: dict, line: str) -> None:
107
+ if line.startswith("## "):
108
+ _flush_doc_entry(out, state)
109
+ state["in_section"] = line.strip() == "## Exceptions"
110
+ return
111
+ if not state["in_section"]:
112
+ return
113
+ heading = HEADING_RE.match(line)
114
+ if heading:
115
+ _flush_doc_entry(out, state)
116
+ path = heading.group(1)
117
+ # /* is the site-wide CSP baseline, not a per-path exception. The
118
+ # headers side filters it out too — keep the doc parser symmetric.
119
+ if path == "/*":
120
+ state["path"] = None
121
+ state["current"] = None
122
+ return
123
+ state["path"] = path
124
+ state["current"] = _new_entry()
125
+ return
126
+ if state["current"] is None:
127
+ return
128
+ field = FIELD_RE.match(line)
129
+ if field:
130
+ _apply_doc_field(state["current"], field.group(1).strip(), field.group(2).strip())
131
+
132
+
133
+ def _parse_exceptions_doc(doc_path: Path) -> dict[str, dict]:
134
+ if not doc_path.exists():
135
+ return {}
136
+ out: dict[str, dict] = {}
137
+ state: dict = {"in_section": False, "path": None, "current": None}
138
+ for raw_line in doc_path.read_text().splitlines():
139
+ _handle_doc_line(out, state, raw_line.rstrip())
140
+ _flush_doc_entry(out, state)
141
+ return out
142
+
143
+
144
+ # ── Comparison ──────────────────────────────────────────────────────
145
+
146
+
147
+ def _headers_exception_map(rules: list[dict]) -> dict[str, set[str]]:
148
+ out: dict[str, set[str]] = {}
149
+ for rule in rules:
150
+ path = rule["for"]
151
+ if path == "/*" or rule["csp"] is None:
152
+ continue
153
+ external = _extract_csp_origins(rule["csp"])
154
+ if external:
155
+ out[path] = external
156
+ return out
157
+
158
+
159
+ def _sri_issues(path: str, sri: str) -> list[dict]:
160
+ if "TODO" in sri.upper():
161
+ return [{
162
+ "severity": "warn",
163
+ "path": path,
164
+ "message": f"`{path}` Loader SRI is a placeholder ({sri}) — refresh before this exception ships",
165
+ }]
166
+ if not sri:
167
+ return [{
168
+ "severity": "warn",
169
+ "path": path,
170
+ "message": f"`{path}` has no Loader SRI — acceptable if the third party does not support SRI; document why in the entry",
171
+ }]
172
+ return []
173
+
174
+
175
+ def _issues_for_documented_path(path: str, required: set[str], entry: dict, source_label: str) -> list[dict]:
176
+ issues: list[dict] = []
177
+ missing_origins = required - entry["origins"]
178
+ if missing_origins:
179
+ issues.append({
180
+ "severity": "error",
181
+ "path": path,
182
+ "message": f"`{path}` allows {', '.join(sorted(missing_origins))} in {source_label} but those origins are not listed in CSP_EXCEPTIONS.md",
183
+ })
184
+ missing_fields = REQUIRED_FIELDS - entry["fields_seen"]
185
+ if missing_fields:
186
+ issues.append({
187
+ "severity": "error",
188
+ "path": path,
189
+ "message": f"`{path}` is missing required field(s) in CSP_EXCEPTIONS.md: {', '.join(sorted(missing_fields))}",
190
+ })
191
+ issues.extend(_sri_issues(path, entry["sri"] or ""))
192
+ return issues
193
+
194
+
195
+ def _compare(rules: list[dict], doc_entries: dict[str, dict], source_label: str) -> list[dict]:
196
+ issues: list[dict] = []
197
+ headers_map = _headers_exception_map(rules)
198
+ for path, required in headers_map.items():
199
+ entry = doc_entries.get(path)
200
+ if entry is None:
201
+ issues.append({
202
+ "severity": "error",
203
+ "path": path,
204
+ "message": f"`{path}` in {source_label} allows external origins ({', '.join(sorted(required))}) but has no entry in CSP_EXCEPTIONS.md",
205
+ })
206
+ continue
207
+ issues.extend(_issues_for_documented_path(path, required, entry, source_label))
208
+ for path in doc_entries:
209
+ if path not in headers_map:
210
+ issues.append({
211
+ "severity": "error",
212
+ "path": path,
213
+ "message": f"`{path}` is documented in CSP_EXCEPTIONS.md but no matching CSP exception exists in {source_label}",
214
+ })
215
+ return issues
216
+
217
+
218
+ # ── Report writers ─────────────────────────────────────────────────
219
+
220
+
221
+ def _overall_status(issues: list[dict]) -> str:
222
+ if any(i["severity"] == "error" for i in issues):
223
+ return "❌ FAIL"
224
+ if any(i["severity"] == "warn" for i in issues):
225
+ return "⚠️ WARN"
226
+ return "✅ PASS"
227
+
228
+
229
+ def _md_issue_section(title: str, severity: str, issues: list[dict]) -> list[str]:
230
+ bucket = [i for i in issues if i["severity"] == severity]
231
+ if not bucket:
232
+ return []
233
+ lines = [title, ""]
234
+ lines.extend(f"- **{i['path']}** — {i['message']}" for i in bucket)
235
+ lines.append("")
236
+ return lines
237
+
238
+
239
+ def _md_fix_section(source_label: str) -> list[str]:
240
+ return [
241
+ "## How to Fix",
242
+ "",
243
+ "- **Missing doc entry** → add a `### \\`/path\\`` heading under `## Exceptions` in `docs/security/CSP_EXCEPTIONS.md` with all required fields.",
244
+ f"- **Mismatched origins** → make `Origin allowed` / `Directives added` in the doc list every external origin in the corresponding `{source_label}` entry.",
245
+ f"- **Stale doc entry** → remove the heading, or restore the matching entry in `{source_label}`.",
246
+ "- **Placeholder SRI** → recompute with the procedure documented in `CSP_EXCEPTIONS.md` and update both the doc and the page that loads the third-party script.",
247
+ "",
248
+ ]
249
+
250
+
251
+ def _build_md_report(issues: list[dict], summary: dict) -> str:
252
+ source_label = summary.get("source", "headers source")
253
+ lines: list[str] = [
254
+ "# 🔐 CSP Exceptions Report",
255
+ "",
256
+ f"**Status:** {_overall_status(issues)}",
257
+ "",
258
+ f"- Headers source: `{source_label}` (format: `{summary.get('format', 'unknown')}`)",
259
+ f"- Documented exceptions in `docs/security/CSP_EXCEPTIONS.md`: {summary['doc_entries']}",
260
+ f"- CSP exceptions in source (non-`/*`): {summary['headers_exceptions']}",
261
+ "",
262
+ ]
263
+ if not issues:
264
+ lines.extend([f"No drift detected. `{source_label}` and `CSP_EXCEPTIONS.md` agree.", ""])
265
+ else:
266
+ lines.extend(_md_issue_section("## ❌ Errors", "error", issues))
267
+ lines.extend(_md_issue_section("## ⚠️ Warnings", "warn", issues))
268
+ lines.extend(_md_fix_section(source_label))
269
+ return "\n".join(lines) + "\n"
270
+
271
+
272
+ def _write_reports(issues: list[dict], summary: dict) -> None:
273
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
274
+ REPORT_JSON.write_text(json.dumps({"summary": summary, "issues": issues}, indent=2) + "\n")
275
+ REPORT_MD.write_text(_build_md_report(issues, summary))
276
+
277
+
278
+ def _print_results(headers_count: int, doc_count: int, issues: list[dict], source_label: str) -> None:
279
+ output.status("🔐", "CSP exceptions check")
280
+ output._emit(f" source: {source_label}")
281
+ output._emit(f" CSP exceptions in source: {headers_count}")
282
+ output._emit(f" documented in CSP_EXCEPTIONS.md: {doc_count}")
283
+ output.separator()
284
+ if not issues:
285
+ output.success("No drift detected.")
286
+ return
287
+ for i in issues:
288
+ icon = "❌" if i["severity"] == "error" else "⚠️ "
289
+ output._emit(f" {icon} {i['path']}: {i['message']}")
290
+ output.separator()
291
+
292
+
293
+ def run(_args: list[str] | None = None) -> int:
294
+ source_path, format_name, skip_reason = _resolve_source()
295
+ if source_path is None:
296
+ output.info(f"CSP exceptions check: {skip_reason} — skipping.")
297
+ return 0
298
+ if skip_reason:
299
+ output.info(f"CSP exceptions check: {skip_reason} — skipping.")
300
+ return 0
301
+ if format_name not in {*headers_adapters.ADAPTERS.keys(), "auto"}:
302
+ output.error(
303
+ f"Unknown headers.format '{format_name}' in .slopstopper.yml. "
304
+ f"Known: {', '.join(sorted(headers_adapters.ADAPTERS.keys()))} or 'auto'."
305
+ )
306
+ return 2
307
+ if not EXCEPTIONS_DOC.exists():
308
+ output.error("docs/security/CSP_EXCEPTIONS.md not found")
309
+ return 2
310
+
311
+ rules = headers_adapters.parse(source_path, format_name)
312
+ doc_entries = _parse_exceptions_doc(EXCEPTIONS_DOC)
313
+ headers_count = len(_headers_exception_map(rules))
314
+ issues = _compare(rules, doc_entries, str(source_path))
315
+
316
+ _write_reports(issues, {
317
+ "doc_entries": len(doc_entries),
318
+ "headers_exceptions": headers_count,
319
+ "source": str(source_path),
320
+ "format": format_name,
321
+ })
322
+ _print_results(headers_count, len(doc_entries), issues, str(source_path))
323
+
324
+ if any(i["severity"] == "error" for i in issues):
325
+ output.error("Drift detected. See .ss/reports/csp/csp-exceptions-report.md for full details.")
326
+ return 1
327
+ if issues:
328
+ output.warn("Warnings only — passing.")
329
+ return 0
@@ -0,0 +1,269 @@
1
+ """Core Web Vitals audit (Lighthouse CI wrapper).
2
+
3
+ Implements the reliability:cwv flow:
4
+
5
+ slopstopper run reliability:cwv -- --url https://your-site.example.com
6
+
7
+ which is, under the covers:
8
+
9
+ npx lhci autorun --collect.url="$CWV_URL" --config=<resolved lhci config>
10
+
11
+ Subprocess-invokes `npx lhci` — Lighthouse CI is Apache-2.0; the
12
+ slopstopper-cli wheel ships zero Lighthouse code.
13
+
14
+ After lhci finishes, cwv.py reads the latest `.lighthouseci/lhr-*.json`,
15
+ extracts the four headline metrics (Performance, LCP, TBT, CLS) plus
16
+ FCP, and writes `.ss/reports/cwv/cwv-report.md` with the threshold
17
+ table. emit.py then handles PR-comment / issue from that report — same
18
+ shape as every other check.
19
+
20
+ Configuration: thresholds and the lhci config path live in the
21
+ `.ss/lighthouserc.json` override (or the package-data fallback under
22
+ `cli/slopstopper/data/lighthouserc.json`). cwv.py's threshold-table
23
+ limits mirror that file so the rendered table matches what lhci
24
+ actually enforced.
25
+
26
+ Exit codes:
27
+ 0 — lhci passed all thresholds
28
+ non-zero — lhci failed thresholds (report still written) or URL/config missing
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import os
36
+ import re
37
+ import shutil
38
+ import subprocess
39
+ import sys
40
+ from pathlib import Path
41
+
42
+ from slopstopper import output, templates
43
+
44
+
45
+ REPORT_DIR = Path(".ss/reports/cwv")
46
+ REPORT_MD = REPORT_DIR / "cwv-report.md"
47
+ LHCI_DIR = Path(".lighthouseci")
48
+
49
+ # Threshold table — kept in lockstep with `.ss/lighthouserc.json`'s
50
+ # `assertions` block so the rendered table reflects what lhci actually
51
+ # enforced. If you tune the lhci config, mirror the change here.
52
+ THRESHOLDS = {
53
+ "performance": ("Performance score", "≥ 70", 70, "min"),
54
+ "lcp": ("LCP", "≤ 4 s", 4000, "max"),
55
+ "tbt": ("TBT", "≤ 600 ms", 600, "max"),
56
+ "cls": ("CLS", "≤ 0.25", 0.25, "max"),
57
+ }
58
+
59
+ # Consumed by `slopstopper emit reliability:cwv --target pr-comment|issue`.
60
+ # Discriminator `🚦 Core Web Vitals` matches the pre-flip github-script
61
+ # block's PR comment heading so the same bot comment is reused after
62
+ # the workflow flip.
63
+ META = {
64
+ "report_path": str(REPORT_MD),
65
+ "comment_discriminator": "🚦 Core Web Vitals",
66
+ "issue_title": "❌ Core Web Vitals Below Threshold",
67
+ "issue_labels": ["cwv-failure", "reliability"],
68
+ "issue_followup": "🔔 Core Web Vitals failure recurred in commit",
69
+ }
70
+
71
+
72
+ def _parse_args(args: list[str] | None) -> argparse.Namespace:
73
+ p = argparse.ArgumentParser(prog="slopstopper run reliability:cwv", add_help=False)
74
+ p.add_argument("--url", default=None, help="Site URL to audit (else $CWV_URL)")
75
+ p.add_argument(
76
+ "--prod",
77
+ action="store_true",
78
+ help="Use the stricter prod lighthouserc (default: dev). "
79
+ "Workflows pass this on deployment_status / schedule events.",
80
+ )
81
+ p.add_argument(
82
+ "--config",
83
+ default=None,
84
+ help="Explicit Lighthouse CI config path. Overrides --prod resolution. "
85
+ "Default resolution: .ss/lighthouserc[.prod].json override, else package data.",
86
+ )
87
+ p.add_argument("--help", "-h", action="help")
88
+ return p.parse_args(args or [])
89
+
90
+
91
+ def _npx_available() -> bool:
92
+ return shutil.which("npx") is not None
93
+
94
+
95
+ def _resolve_url(parsed_url: str | None) -> str | None:
96
+ return parsed_url or os.environ.get("CWV_URL")
97
+
98
+
99
+ def _build_cmd(url: str, config_path: str) -> list[str]:
100
+ return [
101
+ "npx", "lhci", "autorun",
102
+ f"--collect.url={url}",
103
+ f"--config={config_path}",
104
+ ]
105
+
106
+
107
+ def _run_lhci(cmd: list[str]) -> tuple[int, str]:
108
+ """Run lhci, tee its output to our stdout, and return (exit_code, captured_output)."""
109
+ proc = subprocess.Popen(
110
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1
111
+ )
112
+ chunks: list[str] = []
113
+ assert proc.stdout is not None
114
+ for line in proc.stdout:
115
+ sys.stdout.write(line)
116
+ sys.stdout.flush()
117
+ chunks.append(line)
118
+ rc = proc.wait()
119
+ return rc, "".join(chunks)
120
+
121
+
122
+ # ── lhci JSON parsing + report rendering ─────────────────────────
123
+
124
+
125
+ def _latest_lhr_json() -> Path | None:
126
+ if not LHCI_DIR.exists():
127
+ return None
128
+ reports = sorted(LHCI_DIR.glob("lhr-*.json"))
129
+ return reports[-1] if reports else None
130
+
131
+
132
+ def _extract_metrics(lhr: dict) -> dict[str, float | None]:
133
+ """Pull the four headline metrics + FCP from a Lighthouse result JSON."""
134
+ categories = lhr.get("categories") or {}
135
+ audits = lhr.get("audits") or {}
136
+ perf_cat = categories.get("performance") or {}
137
+
138
+ def _audit_value(key: str) -> float | None:
139
+ audit = audits.get(key) or {}
140
+ value = audit.get("numericValue")
141
+ return float(value) if isinstance(value, (int, float)) else None
142
+
143
+ perf_score = perf_cat.get("score")
144
+ return {
145
+ "performance": round(perf_score * 100) if isinstance(perf_score, (int, float)) else None,
146
+ "lcp": _audit_value("largest-contentful-paint"),
147
+ "tbt": _audit_value("total-blocking-time"),
148
+ "cls": _audit_value("cumulative-layout-shift"),
149
+ "fcp": _audit_value("first-contentful-paint"),
150
+ }
151
+
152
+
153
+ def _format_value(metric: str, value: float | None) -> str:
154
+ if value is None:
155
+ return "N/A"
156
+ if metric == "performance":
157
+ return f"{int(value)}/100"
158
+ if metric == "cls":
159
+ return f"{value:.3f}"
160
+ return f"{int(round(value))} ms"
161
+
162
+
163
+ def _passes(metric: str, value: float | None) -> bool | None:
164
+ if value is None:
165
+ return None
166
+ _, _, threshold, direction = THRESHOLDS[metric]
167
+ return value >= threshold if direction == "min" else value <= threshold
168
+
169
+
170
+ def _icon(passed: bool | None) -> str:
171
+ if passed is None:
172
+ return "⚠️"
173
+ return "✅" if passed else "❌"
174
+
175
+
176
+ def _extract_report_url(output: str) -> str | None:
177
+ """lhci prints the storage URL to stdout. Capture the first one."""
178
+ match = re.search(r"https://storage\.googleapis\.com/\S+", output)
179
+ return match.group(0) if match else None
180
+
181
+
182
+ def _build_report_md(
183
+ url: str,
184
+ metrics: dict[str, float | None],
185
+ report_url: str | None,
186
+ overall_pass: bool,
187
+ ) -> str:
188
+ status = "✅ PASSED" if overall_pass else "❌ FAILED"
189
+ lines: list[str] = [
190
+ f"## 🚦 Core Web Vitals {status}",
191
+ "",
192
+ f"**URL audited:** {url}",
193
+ "",
194
+ "| Metric | Threshold | Status |",
195
+ "|--------|-----------|--------|",
196
+ ]
197
+ for key, (label, threshold_str, _, _) in THRESHOLDS.items():
198
+ value = metrics.get(key)
199
+ lines.append(
200
+ f"| {label} | {threshold_str} | {_icon(_passes(key, value))} {_format_value(key, value)} |"
201
+ )
202
+ lines.append("")
203
+ if report_url:
204
+ lines.append(f"[📊 Full Lighthouse Report]({report_url})")
205
+ lines.append("")
206
+ lines.append("### How to run locally")
207
+ lines.append("")
208
+ lines.append("```bash")
209
+ lines.append("slopstopper run reliability:cwv -- --url https://your-site.example.com")
210
+ lines.append("```")
211
+ lines.append("")
212
+ return "\n".join(lines)
213
+
214
+
215
+ def _write_report(url: str, output: str, lhci_exit: int) -> None:
216
+ """Parse the latest lhr-*.json + lhci stdout, and write the markdown report."""
217
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
218
+ lhr_path = _latest_lhr_json()
219
+ if lhr_path is None:
220
+ REPORT_MD.write_text(
221
+ "## 🚦 Core Web Vitals ⚠️ NO REPORT\n\n"
222
+ "Lighthouse CI did not produce a report JSON under `.lighthouseci/`. "
223
+ "Check the lhci output above for the cause.\n"
224
+ )
225
+ return
226
+ try:
227
+ lhr = json.loads(lhr_path.read_text())
228
+ except (OSError, json.JSONDecodeError) as e:
229
+ REPORT_MD.write_text(
230
+ f"## 🚦 Core Web Vitals ⚠️ PARSE ERROR\n\nCould not parse {lhr_path}: {e}\n"
231
+ )
232
+ return
233
+ metrics = _extract_metrics(lhr)
234
+ report_url = _extract_report_url(output)
235
+ REPORT_MD.write_text(
236
+ _build_report_md(url, metrics, report_url, overall_pass=lhci_exit == 0)
237
+ )
238
+
239
+
240
+ # ── CLI entrypoint ───────────────────────────────────────────────
241
+
242
+
243
+ def run(args: list[str] | None = None) -> int:
244
+ if not _npx_available():
245
+ output.error("npx is not available — install Node.js to run Lighthouse CI")
246
+ return 1
247
+
248
+ parsed = _parse_args(args)
249
+ url = _resolve_url(parsed.url)
250
+ if not url:
251
+ output.error("CWV target URL is required")
252
+ output._emit("Usage:")
253
+ output._emit(" slopstopper run reliability:cwv -- --url https://your-site.example.com")
254
+ output._emit(" CWV_URL=https://your-site slopstopper run reliability:cwv")
255
+ return 1
256
+
257
+ config_path = (
258
+ Path(parsed.config) if parsed.config else templates.lighthouserc(prod=parsed.prod)
259
+ )
260
+ if not config_path.exists():
261
+ output.error(f"Lighthouse CI config not found at {config_path}")
262
+ return 1
263
+
264
+ output.status("🚦", f"Running Core Web Vitals audit against: {url}")
265
+ cmd = _build_cmd(url, str(config_path))
266
+ rc, captured = _run_lhci(cmd)
267
+ _write_report(url, captured, rc)
268
+ output.footer(REPORT_DIR, [REPORT_MD.name])
269
+ return rc