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.
- slopstopper/__init__.py +1 -0
- slopstopper/__main__.py +4 -0
- slopstopper/checks/__init__.py +41 -0
- slopstopper/checks/accessibility.py +133 -0
- slopstopper/checks/broken_links.py +128 -0
- slopstopper/checks/complexity.py +237 -0
- slopstopper/checks/csp_exceptions.py +329 -0
- slopstopper/checks/cwv.py +269 -0
- slopstopper/checks/dast.py +331 -0
- slopstopper/checks/dependencies.py +195 -0
- slopstopper/checks/docs_accuracy.py +298 -0
- slopstopper/checks/docs_size.py +245 -0
- slopstopper/checks/docs_structure.py +258 -0
- slopstopper/checks/entry_files.py +190 -0
- slopstopper/checks/sast.py +227 -0
- slopstopper/checks/secrets.py +148 -0
- slopstopper/checks/seo.py +515 -0
- slopstopper/checks/smoke.py +101 -0
- slopstopper/cli.py +670 -0
- slopstopper/config.py +193 -0
- slopstopper/dast_gate.py +257 -0
- slopstopper/data/lighthouserc.json +20 -0
- slopstopper/data/lighthouserc.prod.json +24 -0
- slopstopper/data/playwright.config.js +37 -0
- slopstopper/data/server.js +208 -0
- slopstopper/data/tests/accessibility.spec.ts +87 -0
- slopstopper/data/tests/broken-links.spec.ts +54 -0
- slopstopper/data/tests/smoke.spec.ts +77 -0
- slopstopper/discovery.py +366 -0
- slopstopper/emit.py +258 -0
- slopstopper/headers_adapters/__init__.py +54 -0
- slopstopper/headers_adapters/cloudflare_adapter.py +55 -0
- slopstopper/headers_adapters/json_adapter.py +29 -0
- slopstopper/output.py +127 -0
- slopstopper/templates.py +134 -0
- slopstopper_cli-0.3.0.dist-info/METADATA +123 -0
- slopstopper_cli-0.3.0.dist-info/RECORD +40 -0
- slopstopper_cli-0.3.0.dist-info/WHEEL +4 -0
- slopstopper_cli-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|