skilldrop-cli 0.16.34 → 0.16.36

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 (212) hide show
  1. package/README.md +1 -0
  2. package/agents/README.md +4 -1
  3. package/agents/docs-reviewer.md +47 -0
  4. package/agents/finding-adjudicator.md +49 -0
  5. package/agents/test-reviewer.md +52 -0
  6. package/catalogue.json +17 -7
  7. package/model-routing.json +40 -0
  8. package/package.json +1 -1
  9. package/packs/ai-engineering/skills/agent-config-review/SKILL.md +116 -0
  10. package/packs/ai-engineering/skills/agent-config-review/evals/eval_queries.json +12 -0
  11. package/packs/ai-engineering/skills/agent-config-review/evals/evals.json +75 -0
  12. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/db-migrator.md +8 -0
  13. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/reviewer.md +9 -0
  14. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.json +48 -0
  15. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.local.json +5 -0
  16. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.mcp.json +22 -0
  17. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/package.json +12 -0
  18. package/packs/ai-engineering/skills/agent-config-review/evals/files/2/.cursor/mcp.json +17 -0
  19. package/packs/ai-engineering/skills/agent-config-review/evals/files/2/config.toml +21 -0
  20. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/my-user-settings.json +11 -0
  21. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/agents/release-notes.md +13 -0
  22. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/settings.json +39 -0
  23. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.mcp.json +18 -0
  24. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/package.json +14 -0
  25. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/.github/workflows/codex-triage.yml +29 -0
  26. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/AGENTS.md +16 -0
  27. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/codex/config.toml +34 -0
  28. package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.claude/settings.json +22 -0
  29. package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.mcp.json +9 -0
  30. package/packs/ai-engineering/skills/agent-config-review/examples/python-repo-tighten.md +81 -0
  31. package/packs/ai-engineering/skills/agent-config-review/manifest.json +60 -0
  32. package/packs/ai-engineering/skills/agent-config-review/reference.md +205 -0
  33. package/packs/ai-engineering/skills/agent-config-review/scripts/agent_config_scan.py +989 -0
  34. package/packs/ai-engineering/skills/agent-incident-response/SKILL.md +175 -0
  35. package/packs/ai-engineering/skills/agent-incident-response/evals/eval_queries.json +14 -0
  36. package/packs/ai-engineering/skills/agent-incident-response/evals/evals.json +107 -0
  37. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/agent-session-excerpt.txt +11 -0
  38. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/ci-build-log.txt +24 -0
  39. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/incident-notes.md +19 -0
  40. package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/mcp-config.json +20 -0
  41. package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/session-transcript.txt +8 -0
  42. package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/agent-session.log +70 -0
  43. package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/infra/staging-db.tf +25 -0
  44. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/job.yaml +19 -0
  45. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/run.log +13 -0
  46. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/usage-export-2026-10-20.csv +13 -0
  47. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/.mcp.json +20 -0
  48. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/git-log.txt +12 -0
  49. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync-excerpt.txt +30 -0
  50. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync.log +15 -0
  51. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/ssh-config.txt +16 -0
  52. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/.github/workflows/ci.yml +28 -0
  53. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/SECURITY.md +8 -0
  54. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/issue-479.md +35 -0
  55. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/package.json +15 -0
  56. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/pr-482.diff +20 -0
  57. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/run-log.txt +30 -0
  58. package/packs/ai-engineering/skills/agent-incident-response/examples/injected-issue-comment.md +57 -0
  59. package/packs/ai-engineering/skills/agent-incident-response/manifest.json +55 -0
  60. package/packs/ai-engineering/skills/agent-incident-response/reference.md +152 -0
  61. package/packs/ai-engineering/skills/agent-incident-response/scripts/find_secret_exposure.py +239 -0
  62. package/packs/ai-engineering/skills/agent-incident-response/scripts/usage_split.py +194 -0
  63. package/packs/converters/skills/file-to-markdown/SKILL.md +3 -2
  64. package/packs/converters/skills/file-to-markdown/evals/eval_queries.json +4 -0
  65. package/packs/converters/skills/file-to-markdown/manifest.json +2 -2
  66. package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +340 -7
  67. package/packs/converters/skills/md-to-confluence/SKILL.md +139 -0
  68. package/packs/converters/skills/md-to-confluence/evals/eval_queries.json +42 -0
  69. package/packs/converters/skills/md-to-confluence/evals/evals.json +89 -0
  70. package/packs/converters/skills/md-to-confluence/evals/files/1/img/latency-dashboard.png +0 -0
  71. package/packs/converters/skills/md-to-confluence/evals/files/1/payments-runbook.md +60 -0
  72. package/packs/converters/skills/md-to-confluence/evals/files/2/deploy-checklist.md +33 -0
  73. package/packs/converters/skills/md-to-confluence/evals/files/3/img/after/screen.png +0 -0
  74. package/packs/converters/skills/md-to-confluence/evals/files/3/img/before/screen.png +0 -0
  75. package/packs/converters/skills/md-to-confluence/evals/files/3/release-notes-4.2.md +49 -0
  76. package/packs/converters/skills/md-to-confluence/evals/files/4/docs/img/topology.png +0 -0
  77. package/packs/converters/skills/md-to-confluence/evals/files/4/docs/ingest-pipeline-design.md +65 -0
  78. package/packs/converters/skills/md-to-confluence/evals/files/5/support/troubleshooting-sso.md +57 -0
  79. package/packs/converters/skills/md-to-confluence/examples/inputs/img/queue-depth.png +0 -0
  80. package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.fixed.md +21 -0
  81. package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.md +21 -0
  82. package/packs/converters/skills/md-to-confluence/examples/queue-runbook.md +57 -0
  83. package/packs/converters/skills/md-to-confluence/manifest.json +52 -0
  84. package/packs/converters/skills/md-to-confluence/reference.md +92 -0
  85. package/packs/converters/skills/md-to-confluence/scripts/md_to_confluence.py +821 -0
  86. package/packs/converters/skills/md-to-pptx/SKILL.md +131 -0
  87. package/packs/converters/skills/md-to-pptx/evals/eval_queries.json +12 -0
  88. package/packs/converters/skills/md-to-pptx/evals/evals.json +84 -0
  89. package/packs/converters/skills/md-to-pptx/evals/files/1/img/defect-burndown.png +0 -0
  90. package/packs/converters/skills/md-to-pptx/evals/files/1/status-report.md +75 -0
  91. package/packs/converters/skills/md-to-pptx/evals/files/3/brand.json +12 -0
  92. package/packs/converters/skills/md-to-pptx/evals/files/3/night-shift-induction.md +53 -0
  93. package/packs/converters/skills/md-to-pptx/evals/files/4/charts/ontime.png +0 -0
  94. package/packs/converters/skills/md-to-pptx/evals/files/4/qbr-q3.md +85 -0
  95. package/packs/converters/skills/md-to-pptx/evals/files/5/vendor-sync-notes.md +28 -0
  96. package/packs/converters/skills/md-to-pptx/examples/support-weekly-input.md +48 -0
  97. package/packs/converters/skills/md-to-pptx/examples/support-weekly.md +75 -0
  98. package/packs/converters/skills/md-to-pptx/examples/tickets-by-week.png +0 -0
  99. package/packs/converters/skills/md-to-pptx/manifest.json +49 -0
  100. package/packs/converters/skills/md-to-pptx/reference.md +139 -0
  101. package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1841 -0
  102. package/packs/dev-team/loops/modernize/LOOP.md +124 -0
  103. package/packs/dev-team/loops/modernize/evals/eval_queries.json +9 -0
  104. package/packs/dev-team/loops/modernize/evals/evals.json +27 -0
  105. package/packs/dev-team/loops/modernize/loop.json +60 -0
  106. package/packs/dev-team/skills/behaviour-equivalence/SKILL.md +155 -0
  107. package/packs/dev-team/skills/behaviour-equivalence/evals/eval_queries.json +11 -0
  108. package/packs/dev-team/skills/behaviour-equivalence/evals/evals.json +76 -0
  109. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/cases.jsonl +12 -0
  110. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/new.jsonl +12 -0
  111. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/notes.md +15 -0
  112. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old.jsonl +12 -0
  113. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old_rerun.jsonl +12 -0
  114. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v1.py +45 -0
  115. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v2.py +65 -0
  116. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/rules.json +10 -0
  117. package/packs/dev-team/skills/behaviour-equivalence/evals/files/3/diff-summary.txt +16 -0
  118. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/diff.txt +13 -0
  119. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py2.py +16 -0
  120. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py3.py +24 -0
  121. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/input.csv +13 -0
  122. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/new_output.csv +13 -0
  123. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/old_output.csv +13 -0
  124. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/TEAM_NOTES.md +19 -0
  125. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/normalisation.yaml +12 -0
  126. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/new_run.jsonl +8 -0
  127. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run1.jsonl +8 -0
  128. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run2.jsonl +8 -0
  129. package/packs/dev-team/skills/behaviour-equivalence/examples/delivery-date.md +78 -0
  130. package/packs/dev-team/skills/behaviour-equivalence/manifest.json +33 -0
  131. package/packs/dev-team/skills/behaviour-equivalence/reference.md +97 -0
  132. package/packs/dev-team/skills/behaviour-equivalence/scripts/golden_diff.py +524 -0
  133. package/packs/dev-team/skills/behaviour-equivalence/templates/accepted.json +10 -0
  134. package/packs/dev-team/skills/behaviour-equivalence/templates/rules.json +7 -0
  135. package/packs/frontend/pack.json +55 -0
  136. package/packs/frontend/skills/rendering-strategy/SKILL.md +149 -0
  137. package/packs/frontend/skills/rendering-strategy/evals/eval_queries.json +38 -0
  138. package/packs/frontend/skills/rendering-strategy/evals/evals.json +77 -0
  139. package/packs/frontend/skills/rendering-strategy/evals/files/1/fernhill-app.md +40 -0
  140. package/packs/frontend/skills/rendering-strategy/evals/files/3/routes.md +33 -0
  141. package/packs/frontend/skills/rendering-strategy/evals/files/4/build-notes.txt +5 -0
  142. package/packs/frontend/skills/rendering-strategy/evals/files/4/nuxt.config.ts +19 -0
  143. package/packs/frontend/skills/rendering-strategy/evals/files/4/pages/recipes/[slug].vue +37 -0
  144. package/packs/frontend/skills/rendering-strategy/evals/files/4/routes.md +15 -0
  145. package/packs/frontend/skills/rendering-strategy/evals/files/5/routes.txt +18 -0
  146. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/hooks.server.ts +14 -0
  147. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/(marketing)/pricing/+page.server.ts +10 -0
  148. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/+layout.server.ts +10 -0
  149. package/packs/frontend/skills/rendering-strategy/evals/files/5/svelte.config.js +10 -0
  150. package/packs/frontend/skills/rendering-strategy/evals/files/5/traffic.md +18 -0
  151. package/packs/frontend/skills/rendering-strategy/examples/saas-site.md +42 -0
  152. package/packs/frontend/skills/rendering-strategy/manifest.json +50 -0
  153. package/packs/frontend/skills/rendering-strategy/reference.md +116 -0
  154. package/packs/frontend/skills/rendering-strategy/scripts/render_load.py +181 -0
  155. package/packs/frontend/skills/web-performance-review/SKILL.md +106 -0
  156. package/packs/frontend/skills/web-performance-review/evals/eval_queries.json +38 -0
  157. package/packs/frontend/skills/web-performance-review/evals/evals.json +76 -0
  158. package/packs/frontend/skills/web-performance-review/evals/files/1/psi.json +1065 -0
  159. package/packs/frontend/skills/web-performance-review/evals/files/2/after.json +359 -0
  160. package/packs/frontend/skills/web-performance-review/evals/files/2/before.json +375 -0
  161. package/packs/frontend/skills/web-performance-review/evals/files/4/psi-pdp-mobile-2026-10-19.json +171 -0
  162. package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-attribution-oct14-19.csv +6 -0
  163. package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-daily.csv +15 -0
  164. package/packs/frontend/skills/web-performance-review/evals/files/5/crux-home-phone.json +35 -0
  165. package/packs/frontend/skills/web-performance-review/evals/files/5/lighthouse-home-mobile.json +173 -0
  166. package/packs/frontend/skills/web-performance-review/examples/quayside-article-lhr.json +480 -0
  167. package/packs/frontend/skills/web-performance-review/examples/quayside-article.md +103 -0
  168. package/packs/frontend/skills/web-performance-review/manifest.json +48 -0
  169. package/packs/frontend/skills/web-performance-review/reference.md +105 -0
  170. package/packs/frontend/skills/web-performance-review/scripts/lh_summary.py +743 -0
  171. package/packs/product-manager/skills/lean-canvas/SKILL.md +162 -0
  172. package/packs/product-manager/skills/lean-canvas/evals/eval_queries.json +42 -0
  173. package/packs/product-manager/skills/lean-canvas/evals/evals.json +82 -0
  174. package/packs/product-manager/skills/lean-canvas/evals/files/1/pawpass-notes.md +54 -0
  175. package/packs/product-manager/skills/lean-canvas/evals/files/3/lunchloop-pitch.md +30 -0
  176. package/packs/product-manager/skills/lean-canvas/evals/files/4/clubkit-notes.md +44 -0
  177. package/packs/product-manager/skills/lean-canvas/evals/files/5/cost-model.csv +5 -0
  178. package/packs/product-manager/skills/lean-canvas/evals/files/5/proposal.md +40 -0
  179. package/packs/product-manager/skills/lean-canvas/evals/files/5/stakeholder-emails.txt +18 -0
  180. package/packs/product-manager/skills/lean-canvas/examples/bike-repair.md +72 -0
  181. package/packs/product-manager/skills/lean-canvas/manifest.json +34 -0
  182. package/packs/product-manager/skills/lean-canvas/reference.md +102 -0
  183. package/packs/product-manager/skills/lean-canvas/scripts/unit_economics.py +211 -0
  184. package/packs/solution-architect/skills/api-contract-draft/SKILL.md +2 -2
  185. package/packs/solution-architect/skills/api-contract-draft/manifest.json +28 -6
  186. package/packs/solution-architect/skills/event-contract-draft/SKILL.md +167 -0
  187. package/packs/solution-architect/skills/event-contract-draft/evals/eval_queries.json +38 -0
  188. package/packs/solution-architect/skills/event-contract-draft/evals/evals.json +76 -0
  189. package/packs/solution-architect/skills/event-contract-draft/evals/files/1/order-events-brief.md +45 -0
  190. package/packs/solution-architect/skills/event-contract-draft/evals/files/4/NOTES.md +23 -0
  191. package/packs/solution-architect/skills/event-contract-draft/evals/files/4/schemas/shipment-update.json +40 -0
  192. package/packs/solution-architect/skills/event-contract-draft/evals/files/5/invoice.example.json +25 -0
  193. package/packs/solution-architect/skills/event-contract-draft/examples/order-service.asyncapi.json +246 -0
  194. package/packs/solution-architect/skills/event-contract-draft/examples/order-service.md +92 -0
  195. package/packs/solution-architect/skills/event-contract-draft/manifest.json +25 -0
  196. package/packs/solution-architect/skills/event-contract-draft/reference.md +113 -0
  197. package/packs/solution-architect/skills/event-contract-draft/scripts/check_asyncapi.py +502 -0
  198. package/packs/solution-architect/skills/event-contract-draft/templates/asyncapi-skeleton.json +144 -0
  199. package/packs/sre-oncall/skills/slo-spec/SKILL.md +161 -0
  200. package/packs/sre-oncall/skills/slo-spec/evals/eval_queries.json +38 -0
  201. package/packs/sre-oncall/skills/slo-spec/evals/evals.json +75 -0
  202. package/packs/sre-oncall/skills/slo-spec/evals/files/1/order-service.md +51 -0
  203. package/packs/sre-oncall/skills/slo-spec/evals/files/3/search-slo-status.md +30 -0
  204. package/packs/sre-oncall/skills/slo-spec/evals/files/4/alerts/payments-api.rules.yaml +16 -0
  205. package/packs/sre-oncall/skills/slo-spec/evals/files/4/docs/dependencies.md +8 -0
  206. package/packs/sre-oncall/skills/slo-spec/evals/files/4/metrics/summary-90d.md +51 -0
  207. package/packs/sre-oncall/skills/slo-spec/evals/files/5/ops-notes.md +34 -0
  208. package/packs/sre-oncall/skills/slo-spec/examples/ticket-booking.md +88 -0
  209. package/packs/sre-oncall/skills/slo-spec/manifest.json +34 -0
  210. package/packs/sre-oncall/skills/slo-spec/reference.md +113 -0
  211. package/packs/sre-oncall/skills/slo-spec/scripts/slo_math.py +449 -0
  212. package/packs/sre-oncall/skills/slo-spec/templates/slo-spec.md +35 -0
@@ -0,0 +1,743 @@
1
+ #!/usr/bin/env python3
2
+ """Summarise a Lighthouse report (and field data, when given) for a performance review. Stdlib only.
3
+
4
+ Input is either the Lighthouse result JSON (`lighthouse <url> --output=json`), or a PageSpeed
5
+ Insights API v5 response, which embeds that result as `lighthouseResult` and carries field data
6
+ (CrUX p75) in `loadingExperience` / `originLoadingExperience`. A CrUX API `records:queryRecord`
7
+ response is also read, as field data only.
8
+
9
+ It prints, in order: how the run was made (device, throttling, Lighthouse version, warnings);
10
+ lab metrics against the thresholds; field p75 metrics with good / needs-improvement / poor and
11
+ the Core Web Vitals outcome; the LCP element and its phase breakdown; layout-shift culprits;
12
+ failing opportunities and diagnostics with the metric each one moves; long tasks and third
13
+ parties by main-thread time; render-blocking resources. Given two reports, it also says whether
14
+ they are comparable and prints the lab deltas.
15
+
16
+ Usage:
17
+ lighthouse https://example.test --output=json --output-path=report.json
18
+ python3 lh_summary.py report.json [--json] [-o summary.md]
19
+ python3 lh_summary.py psi.json
20
+ python3 lh_summary.py report.json --field crux.json
21
+ python3 lh_summary.py report.json --lcp-p75 3100 --inp-p75 420 --cls-p75 0.04 # RUM numbers
22
+ python3 lh_summary.py before.json after.json # comparability check + deltas
23
+ cat report.json | python3 lh_summary.py -
24
+
25
+ Exit codes: 0 summary written; 2 bad input (unreadable, not JSON, not a Lighthouse/PSI/CrUX file).
26
+ Estimated savings are Lighthouse's lab estimates for one simulated load. They are not field
27
+ changes, and savings from audits that touch the same resources overlap and must not be added.
28
+ """
29
+ import argparse
30
+ import json
31
+ import re
32
+ import sys
33
+ from urllib.parse import urlsplit
34
+
35
+ # Core Web Vitals and supporting field thresholds at p75: (good <=, poor >).
36
+ FIELD_THRESHOLDS = {
37
+ "LCP": (2500, 4000),
38
+ "INP": (200, 500),
39
+ "CLS": (0.1, 0.25),
40
+ "FCP": (1800, 3000),
41
+ "TTFB": (800, 1800),
42
+ }
43
+ CWV = ("LCP", "INP", "CLS")
44
+
45
+ # Lab metrics: audit id, label, (good <=, poor >) or None, unit.
46
+ LAB_METRICS = (
47
+ ("first-contentful-paint", "FCP", (1800, 3000), "ms"),
48
+ ("largest-contentful-paint", "LCP", (2500, 4000), "ms"),
49
+ ("total-blocking-time", "TBT", (200, 600), "ms"),
50
+ ("cumulative-layout-shift", "CLS", (0.1, 0.25), ""),
51
+ ("speed-index", "Speed Index", None, "ms"),
52
+ ("interactive", "TTI", None, "ms"),
53
+ ("server-response-time", "Server response (lab TTFB)", None, "ms"),
54
+ ("interaction-to-next-paint", "INP (timespan run)", (200, 500), "ms"),
55
+ )
56
+ METRIC_IDS = {m[0] for m in LAB_METRICS if m[0] != "server-response-time"} | {
57
+ "max-potential-fid", "first-meaningful-paint", "metrics"}
58
+ # Audits that describe elements rather than propose a fix; reported in their own sections.
59
+ DESCRIPTIVE = {"largest-contentful-paint-element", "layout-shift-elements",
60
+ "long-tasks", "third-party-summary", "main-thread-tasks", "network-requests",
61
+ "screenshot-thumbnails", "final-screenshot", "diagnostics", "resource-summary",
62
+ "network-rtt", "network-server-latency", "script-treemap-data"}
63
+
64
+ # Audits Lighthouse reports with a zero or missing metric saving even though the fix acts on a
65
+ # vital directly; never label these "load-time only".
66
+ DIRECT_EFFECT = {
67
+ "lcp-lazy-loaded": "LCP", "prioritize-lcp-image": "LCP", "lcp-discovery-insight": "LCP",
68
+ "server-response-time": "LCP, FCP", "document-latency-insight": "LCP, FCP", "redirects": "LCP, FCP",
69
+ "uses-rel-preconnect": "LCP, FCP", "unsized-images": "CLS", "font-display": "CLS, FCP",
70
+ "font-display-insight": "CLS, FCP", "non-composited-animations": "CLS", "cls-culprits-insight": "CLS",
71
+ "layout-shifts": "CLS",
72
+ }
73
+
74
+ PSI_FIELD_KEYS = {
75
+ "LARGEST_CONTENTFUL_PAINT_MS": "LCP",
76
+ "INTERACTION_TO_NEXT_PAINT": "INP",
77
+ "CUMULATIVE_LAYOUT_SHIFT_SCORE": "CLS",
78
+ "FIRST_CONTENTFUL_PAINT_MS": "FCP",
79
+ "EXPERIMENTAL_TIME_TO_FIRST_BYTE": "TTFB",
80
+ }
81
+ CRUX_FIELD_KEYS = {
82
+ "largest_contentful_paint": "LCP",
83
+ "interaction_to_next_paint": "INP",
84
+ "cumulative_layout_shift": "CLS",
85
+ "first_contentful_paint": "FCP",
86
+ "experimental_time_to_first_byte": "TTFB",
87
+ }
88
+ PHASE_NAMES = {
89
+ "ttfb": "TTFB", "timetofirstbyte": "TTFB",
90
+ "loaddelay": "Resource load delay", "resourceloaddelay": "Resource load delay",
91
+ "loadtime": "Resource load duration", "resourceloadtime": "Resource load duration",
92
+ "resourceloadduration": "Resource load duration",
93
+ "renderdelay": "Element render delay", "elementrenderdelay": "Element render delay",
94
+ }
95
+ PHASE_FIX = {
96
+ "TTFB": "slow server or redirects: cache the HTML at the edge, cut redirects, fix backend time",
97
+ "Resource load delay": "LCP resource requested late: check loading=lazy, discovery in the HTML (not JS or CSS), fetchpriority=high or preload",
98
+ "Resource load duration": "LCP resource too heavy: smaller/responsive image, modern format, CDN",
99
+ "Element render delay": "resource arrived but paint waited: render-blocking CSS/JS, client-side rendering, long tasks",
100
+ }
101
+
102
+
103
+ def die(msg):
104
+ print(f"lh_summary: {msg}", file=sys.stderr)
105
+ sys.exit(2)
106
+
107
+
108
+ def load(path):
109
+ try:
110
+ raw = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
111
+ except OSError as exc:
112
+ die(f"cannot read {path}: {exc.strerror}")
113
+ try:
114
+ return json.loads(raw)
115
+ except json.JSONDecodeError as exc:
116
+ die(f"{path} is not JSON ({exc.msg} at line {exc.lineno})")
117
+
118
+
119
+ def classify(doc, path):
120
+ """Return (lhr or None, list of field dicts)."""
121
+ if not isinstance(doc, dict):
122
+ die(f"{path}: expected a JSON object")
123
+ if "steps" in doc and "lhr" not in doc:
124
+ die(f"{path}: this is a Lighthouse user-flow report; export one step's lhr and pass that")
125
+ fields = []
126
+ if "lighthouseResult" in doc or "loadingExperience" in doc:
127
+ for key, scope in (("loadingExperience", "url"), ("originLoadingExperience", "origin")):
128
+ f = psi_field(doc.get(key), scope)
129
+ if f:
130
+ fields.append(f)
131
+ lhr = doc.get("lighthouseResult")
132
+ if lhr is not None and not isinstance(lhr, dict):
133
+ die(f"{path}: lighthouseResult is not an object")
134
+ return lhr, fields
135
+ if "record" in doc and isinstance(doc["record"], dict):
136
+ f = crux_field(doc["record"])
137
+ if not f:
138
+ die(f"{path}: CrUX record has no metrics")
139
+ return None, [f]
140
+ if "lhr" in doc and isinstance(doc["lhr"], dict):
141
+ doc = doc["lhr"]
142
+ if "audits" in doc and "lighthouseVersion" in doc:
143
+ if doc.get("runtimeError"):
144
+ err = doc["runtimeError"]
145
+ die(f"{path}: Lighthouse run failed ({err.get('code')}: {err.get('message')})")
146
+ return doc, fields
147
+ die(f"{path}: not a Lighthouse result, PageSpeed Insights response or CrUX record")
148
+
149
+
150
+ def rate(value, thresholds):
151
+ if value is None or thresholds is None:
152
+ return None
153
+ good, poor = thresholds
154
+ if value <= good:
155
+ return "good"
156
+ if value > poor:
157
+ return "poor"
158
+ return "needs-improvement"
159
+
160
+
161
+ def psi_field(le, scope):
162
+ if not isinstance(le, dict) or not le.get("metrics"):
163
+ return None
164
+ out = {"source": "CrUX via PageSpeed Insights", "scope": scope, "id": le.get("id"),
165
+ "origin_fallback": bool(le.get("origin_fallback")), "metrics": {}}
166
+ for key, name in PSI_FIELD_KEYS.items():
167
+ m = le["metrics"].get(key)
168
+ if not m or m.get("percentile") is None:
169
+ continue
170
+ p75 = m["percentile"]
171
+ if name == "CLS":
172
+ p75 = p75 / 100.0 # PSI reports CLS p75 multiplied by 100
173
+ good_share = None
174
+ for d in m.get("distributions") or []:
175
+ if d.get("min") == 0:
176
+ good_share = d.get("proportion")
177
+ out["metrics"][name] = {"p75": p75, "rating": rate(p75, FIELD_THRESHOLDS[name]),
178
+ "good_share": good_share}
179
+ return out if out["metrics"] else None
180
+
181
+
182
+ def crux_field(rec):
183
+ key = rec.get("key") or {}
184
+ period = rec.get("collectionPeriod") or {}
185
+
186
+ def d(x):
187
+ return "{:04d}-{:02d}-{:02d}".format(x.get("year", 0), x.get("month", 0), x.get("day", 0)) if x else None
188
+ out = {"source": "CrUX API", "scope": "url" if "url" in key else "origin",
189
+ "id": key.get("url") or key.get("origin"), "form_factor": key.get("formFactor", "ALL"),
190
+ "period": [d(period.get("firstDate")), d(period.get("lastDate"))], "metrics": {}}
191
+ for k, name in CRUX_FIELD_KEYS.items():
192
+ m = (rec.get("metrics") or {}).get(k)
193
+ if not m:
194
+ continue
195
+ p75 = (m.get("percentiles") or {}).get("p75")
196
+ if p75 is None:
197
+ continue
198
+ p75 = float(p75)
199
+ good_share = None
200
+ hist = m.get("histogram") or []
201
+ if hist and "density" in hist[0]:
202
+ good_share = hist[0]["density"]
203
+ out["metrics"][name] = {"p75": p75, "rating": rate(p75, FIELD_THRESHOLDS[name]),
204
+ "good_share": good_share}
205
+ return out if out["metrics"] else None
206
+
207
+
208
+ def manual_field(args):
209
+ m = {}
210
+ for name, val in (("LCP", args.lcp_p75), ("INP", args.inp_p75), ("CLS", args.cls_p75)):
211
+ if val is not None:
212
+ m[name] = {"p75": val, "rating": rate(val, FIELD_THRESHOLDS[name]), "good_share": None}
213
+ if not m:
214
+ return None
215
+ return {"source": args.field_source, "scope": "as given", "id": None, "metrics": m}
216
+
217
+
218
+ def cwv_outcome(field):
219
+ m = field["metrics"]
220
+ if "LCP" not in m or "CLS" not in m:
221
+ have = ", ".join(f"{k} {m[k]['rating']}" for k in CWV if k in m) or "no vitals"
222
+ return f"cannot be assessed: no {' or '.join(k for k in ('LCP', 'CLS') if k not in m)} at p75 ({have})"
223
+ failing = [k for k in CWV if k in m and m[k]["rating"] != "good"]
224
+ missing = [k for k in CWV if k not in m]
225
+ if failing:
226
+ return "FAILED: " + ", ".join(f"{k} {m[k]['rating']}" for k in failing)
227
+ return "PASSED" + (f" (no {', '.join(missing)} data)" if missing else "")
228
+
229
+
230
+ # ---------------------------------------------------------------- Lighthouse result readers
231
+
232
+ def audit(lhr, aid):
233
+ a = (lhr.get("audits") or {}).get(aid)
234
+ if not isinstance(a, dict) or a.get("scoreDisplayMode") in ("notApplicable", "error"):
235
+ return None
236
+ return a
237
+
238
+
239
+ def items_of(a):
240
+ if not a:
241
+ return []
242
+ det = a.get("details") or {}
243
+ return [i for i in det.get("items") or [] if isinstance(i, dict)]
244
+
245
+
246
+ def node_desc(node):
247
+ if not isinstance(node, dict):
248
+ return None
249
+ return {k: node.get(k) for k in ("selector", "nodeLabel", "snippet") if node.get(k)}
250
+
251
+
252
+ def origin(url):
253
+ try:
254
+ p = urlsplit(url or "")
255
+ return f"{p.scheme}://{p.netloc}" if p.netloc else (url or "")
256
+ except ValueError:
257
+ return url or ""
258
+
259
+
260
+ def entity_map(lhr):
261
+ out = {}
262
+ for e in lhr.get("entities") or []:
263
+ for o in e.get("origins") or []:
264
+ out[o] = {"name": e.get("name"), "first_party": bool(e.get("isFirstParty"))}
265
+ return out
266
+
267
+
268
+ def run_info(lhr):
269
+ cs = lhr.get("configSettings") or {}
270
+ th = cs.get("throttling") or {}
271
+ method = cs.get("throttlingMethod")
272
+ se = cs.get("screenEmulation") or {}
273
+ info = {
274
+ "url": lhr.get("finalDisplayedUrl") or lhr.get("finalUrl") or lhr.get("mainDocumentUrl") or lhr.get("requestedUrl"),
275
+ "fetch_time": lhr.get("fetchTime"),
276
+ "lighthouse_version": lhr.get("lighthouseVersion"),
277
+ "form_factor": cs.get("formFactor") or ("mobile" if se.get("mobile") else "desktop" if se else None),
278
+ "screen": f"{se.get('width')}x{se.get('height')} @{se.get('deviceScaleFactor')}x" if se.get("width") else None,
279
+ "throttling_method": method,
280
+ "rtt_ms": th.get("requestLatencyMs") if method == "devtools" else th.get("rttMs"),
281
+ "throughput_kbps": th.get("downloadThroughputKbps") if method == "devtools" else th.get("throughputKbps"),
282
+ "cpu_slowdown": th.get("cpuSlowdownMultiplier"),
283
+ "benchmark_index": (lhr.get("environment") or {}).get("benchmarkIndex"),
284
+ "run_warnings": lhr.get("runWarnings") or [],
285
+ }
286
+ if method == "provided":
287
+ info["preset"] = "no throttling applied by Lighthouse (provided)"
288
+ elif info["rtt_ms"] == 150 and info["cpu_slowdown"] == 4:
289
+ info["preset"] = "Lighthouse default mobile: slow 4G (150 ms RTT, ~1.6 Mbps), 4x CPU slowdown"
290
+ elif info["rtt_ms"] == 40 and info["cpu_slowdown"] == 1:
291
+ info["preset"] = "Lighthouse desktop preset: 40 ms RTT, ~10 Mbps, no CPU slowdown"
292
+ else:
293
+ info["preset"] = "custom throttling"
294
+ if method == "simulate":
295
+ info["method_note"] = "simulated: metrics are modelled from an unthrottled load"
296
+ return info
297
+
298
+
299
+ def lab_metrics(lhr):
300
+ out = []
301
+ for aid, label, thr, unit in LAB_METRICS:
302
+ a = audit(lhr, aid)
303
+ if not a or a.get("numericValue") is None:
304
+ continue
305
+ v = a["numericValue"]
306
+ out.append({"id": aid, "metric": label, "value": round(v, 3 if unit == "" else 0),
307
+ "display": a.get("displayValue"), "rating": rate(v, thr),
308
+ "threshold": f"good <= {thr[0]}{unit}, poor > {thr[1]}{unit}" if thr else None})
309
+ perf = (lhr.get("categories") or {}).get("performance") or {}
310
+ score = perf.get("score")
311
+ return {"performance_score": None if score is None else round(score * 100), "metrics": out}
312
+
313
+
314
+ def norm_phase(name):
315
+ return PHASE_NAMES.get(re.sub(r"[^a-z]", "", str(name).lower()), str(name))
316
+
317
+
318
+ def lcp_info(lhr):
319
+ out = {"element": None, "phases": [], "lazy_loaded": None, "fetchpriority_high": None,
320
+ "discovery_checks": [], "lcp_resource_url": None, "prioritize_savings_ms": None}
321
+ sources = [audit(lhr, "largest-contentful-paint-element"), audit(lhr, "lcp-breakdown-insight"),
322
+ audit(lhr, "lcp-phases-insight"), audit(lhr, "lcp-discovery-insight")]
323
+ for a in sources:
324
+ if not a:
325
+ continue
326
+ det = a.get("details") or {}
327
+ blocks = det.get("items") if det.get("type") == "list" else [det]
328
+ for blk in blocks or []:
329
+ if not isinstance(blk, dict):
330
+ continue
331
+ if blk.get("type") == "node" and not out["element"]:
332
+ out["element"] = node_desc(blk)
333
+ if blk.get("type") == "checklist":
334
+ for k, c in (blk.get("items") or {}).items():
335
+ out["discovery_checks"].append({"check": c.get("label", k), "passed": bool(c.get("value"))})
336
+ rows = blk.get("items") if isinstance(blk.get("items"), list) else []
337
+ phases = []
338
+ for it in rows:
339
+ if not isinstance(it, dict):
340
+ continue
341
+ if it.get("node") and not out["element"]:
342
+ out["element"] = node_desc(it["node"])
343
+ name = it.get("phase") or it.get("subpart") or it.get("label")
344
+ t = it.get("timing", it.get("duration"))
345
+ if name is not None and isinstance(t, (int, float)):
346
+ phases.append({"phase": norm_phase(name), "ms": round(t)})
347
+ if phases and not out["phases"]:
348
+ out["phases"] = phases
349
+ total = sum(p["ms"] for p in out["phases"])
350
+ for p in out["phases"]:
351
+ p["share"] = f"{round(100 * p['ms'] / total)}%" if total else None
352
+ if out["phases"]:
353
+ dom = max(out["phases"], key=lambda p: p["ms"])
354
+ out["dominant_phase"] = dom["phase"]
355
+ out["dominant_phase_points_to"] = PHASE_FIX.get(dom["phase"])
356
+ lazy = audit(lhr, "lcp-lazy-loaded")
357
+ if lazy and lazy.get("score") is not None:
358
+ out["lazy_loaded"] = lazy["score"] == 0
359
+ snippet = (out["element"] or {}).get("snippet", "")
360
+ if snippet:
361
+ if re.search(r'loading\s*=\s*["\']?lazy', snippet, re.I):
362
+ out["lazy_loaded"] = True
363
+ out["fetchpriority_high"] = bool(re.search(r'fetchpriority\s*=\s*["\']?high', snippet, re.I))
364
+ m = re.match(r"<\s*([a-zA-Z0-9-]+)", snippet)
365
+ out["element_tag"] = m.group(1).lower() if m else None
366
+ pri = audit(lhr, "prioritize-lcp-image")
367
+ if pri:
368
+ for it in items_of(pri):
369
+ out["lcp_resource_url"] = out["lcp_resource_url"] or it.get("url")
370
+ ms = (pri.get("metricSavings") or {}).get("LCP")
371
+ out["prioritize_savings_ms"] = ms if ms is not None else (pri.get("details") or {}).get("overallSavingsMs")
372
+ return out
373
+
374
+
375
+ def cls_info(lhr):
376
+ shifts = []
377
+ a = audit(lhr, "layout-shifts") or audit(lhr, "layout-shift-elements")
378
+ for it in items_of(a):
379
+ causes = []
380
+ for sub in ((it.get("subItems") or {}).get("items") or []):
381
+ c = sub.get("cause")
382
+ extra = sub.get("extra") or {}
383
+ val = extra.get("value") or extra.get("url") if isinstance(extra, dict) else None
384
+ if c:
385
+ causes.append(f"{c}{' (' + str(val) + ')' if val else ''}")
386
+ shifts.append({"element": node_desc(it.get("node")), "score": it.get("score"), "causes": causes})
387
+ shifts.sort(key=lambda s: -(s["score"] or 0))
388
+ def failing(aid):
389
+ a = audit(lhr, aid)
390
+ return items_of(a) if a and a.get("score") != 1 else []
391
+ unsized = [{"url": it.get("url"), "element": node_desc(it.get("node"))} for it in failing("unsized-images")]
392
+ fonts = [{"url": it.get("url"), "wasted_ms": it.get("wastedMs")} for it in failing("font-display")]
393
+ return {"shifts": shifts, "unsized_images": unsized, "fonts_without_display": fonts}
394
+
395
+
396
+ def item_urls(a):
397
+ urls = set()
398
+ for it in items_of(a):
399
+ u = it.get("url") or (it.get("node") or {}).get("snippet")
400
+ if isinstance(u, str):
401
+ urls.add(u)
402
+ return urls
403
+
404
+
405
+ def opportunities(lhr, top, field_failing=()):
406
+ """field_failing: vitals (LCP, INP, CLS) that real users fail at p75; audits moving them rank first."""
407
+ rows = []
408
+ for aid, a in (lhr.get("audits") or {}).items():
409
+ if aid in METRIC_IDS or aid in DESCRIPTIVE or not isinstance(a, dict):
410
+ continue
411
+ mode = a.get("scoreDisplayMode")
412
+ if mode in ("notApplicable", "error", "manual"):
413
+ continue
414
+ det = a.get("details") or {}
415
+ ms = a.get("metricSavings") or {}
416
+ score = a.get("score")
417
+ failing = isinstance(score, (int, float)) and score < 0.9
418
+ informative_hit = mode == "informative" and any(v for v in ms.values() if isinstance(v, (int, float)))
419
+ if not (failing or informative_hit):
420
+ continue
421
+ affects = {k: v for k, v in ms.items() if isinstance(v, (int, float)) and v > 0}
422
+ cwv_affects = {k: v for k, v in affects.items() if k in ("LCP", "TBT", "INP", "CLS")}
423
+ row = {"id": aid, "title": a.get("title"), "display": a.get("displayValue"),
424
+ "kind": "opportunity" if det.get("type") == "opportunity" else "diagnostic",
425
+ "overall_savings_ms": det.get("overallSavingsMs"),
426
+ "overall_savings_bytes": det.get("overallSavingsBytes"),
427
+ "metric_savings": affects}
428
+ direct = DIRECT_EFFECT.get(aid)
429
+ if direct and not cwv_affects:
430
+ row["note"] = f"acts on {direct} directly; Lighthouse gives no saving estimate for it"
431
+ elif not ms:
432
+ row["note"] = "no per-metric savings in this report (Lighthouse < 11, or not estimated)"
433
+ elif not cwv_affects:
434
+ row["note"] = "LOAD-TIME ONLY in this run: no LCP, TBT or CLS saving estimated"
435
+ moves = set(cwv_affects) | {m.strip() for m in (direct or "").split(",") if m.strip()}
436
+ moves = {"INP" if m == "TBT" else m for m in moves}
437
+ hits = sorted(moves & set(field_failing))
438
+ if hits:
439
+ row["moves_failing_field_vital"] = hits
440
+ row["_rank"] = (1 if hits else 0, 1 if (cwv_affects or direct) else 0, max(cwv_affects.get("LCP", 0), cwv_affects.get("TBT", 0),
441
+ cwv_affects.get("INP", 0)), cwv_affects.get("CLS", 0), row["overall_savings_ms"] or 0)
442
+ row["_urls"] = item_urls(a)
443
+ rows.append(row)
444
+ rows.sort(key=lambda r: r["_rank"], reverse=True)
445
+ for r in rows:
446
+ overlaps = []
447
+ if not (r["overall_savings_ms"] or r["metric_savings"]):
448
+ continue
449
+ for o in rows:
450
+ if o is r or not (o["overall_savings_ms"] or o["metric_savings"]):
451
+ continue
452
+ shared = r["_urls"] & o["_urls"]
453
+ if shared:
454
+ overlaps.append(f"{o['id']} ({len(shared)} shared)")
455
+ if overlaps:
456
+ r["overlaps_with"] = overlaps
457
+ for r in rows:
458
+ r.pop("_rank")
459
+ r.pop("_urls")
460
+ return rows[:top]
461
+
462
+
463
+ def main_thread(lhr, top):
464
+ emap = entity_map(lhr)
465
+
466
+ def who(url):
467
+ o = origin(url)
468
+ e = emap.get(o)
469
+ if e:
470
+ return f"{e['name']}{' (1st party)' if e['first_party'] else ''}"
471
+ return o
472
+ tasks = sorted(items_of(audit(lhr, "long-tasks")), key=lambda t: -(t.get("duration") or 0))
473
+ long_tasks = [{"url": t.get("url"), "by": who(t.get("url")), "duration_ms": round(t.get("duration") or 0),
474
+ "start_ms": round(t.get("startTime") or 0)} for t in tasks[:top]]
475
+ by_origin = {}
476
+ for t in tasks:
477
+ k = who(t.get("url"))
478
+ by_origin.setdefault(k, [0, 0])
479
+ by_origin[k][0] += 1
480
+ by_origin[k][1] += t.get("duration") or 0
481
+ tp = []
482
+ tpa = audit(lhr, "third-party-summary") or audit(lhr, "third-parties-insight")
483
+ for it in items_of(tpa):
484
+ ent = it.get("entity")
485
+ if isinstance(ent, dict):
486
+ ent = ent.get("text") or ent.get("url")
487
+ tp.append({"entity": ent, "main_thread_ms": round(it.get("mainThreadTime") or 0),
488
+ "blocking_ms": round(it.get("blockingTime") or 0) if it.get("blockingTime") is not None else None,
489
+ "transfer_kb": round((it.get("transferSize") or 0) / 1024)})
490
+ tp.sort(key=lambda r: -r["main_thread_ms"])
491
+ boot = sorted(items_of(audit(lhr, "bootup-time")), key=lambda b: -(b.get("total") or 0))
492
+ scripts = [{"url": b.get("url"), "by": who(b.get("url")), "total_ms": round(b.get("total") or 0),
493
+ "scripting_ms": round(b.get("scripting") or 0)} for b in boot[:top] if isinstance(b.get("url"), str)]
494
+ mtw = audit(lhr, "mainthread-work-breakdown")
495
+ return {"long_tasks": long_tasks,
496
+ "long_tasks_by_origin": [{"by": k, "count": v[0], "total_ms": round(v[1])}
497
+ for k, v in sorted(by_origin.items(), key=lambda kv: -kv[1][1])],
498
+ "third_parties": tp, "script_bootup": scripts,
499
+ "main_thread_total_ms": round(mtw["numericValue"]) if mtw and mtw.get("numericValue") is not None else None}
500
+
501
+
502
+ def render_blocking(lhr):
503
+ a = audit(lhr, "render-blocking-resources") or audit(lhr, "render-blocking-insight")
504
+ if not a or a.get("score") == 1:
505
+ return []
506
+ return [{"url": it.get("url"), "kb": round((it.get("totalBytes") or 0) / 1024),
507
+ "wasted_ms": round(it.get("wastedMs") or 0)} for it in items_of(a)]
508
+
509
+
510
+ def summarise(lhr, fields, top):
511
+ r = {"fields": [], "lab": None}
512
+ for f in fields:
513
+ f = dict(f)
514
+ f["core_web_vitals"] = cwv_outcome(f)
515
+ r["fields"].append(f)
516
+ failing = set()
517
+ if r["fields"]:
518
+ f0 = next((f for f in r["fields"] if f["scope"] != "origin"), r["fields"][0])
519
+ failing = {k for k in CWV if k in f0["metrics"] and f0["metrics"][k]["rating"] != "good"}
520
+ if lhr:
521
+ r.update({"run": run_info(lhr), "lab": lab_metrics(lhr), "lcp": lcp_info(lhr), "cls": cls_info(lhr),
522
+ "opportunities": opportunities(lhr, top, failing), "main_thread": main_thread(lhr, top),
523
+ "render_blocking": render_blocking(lhr)})
524
+ return r
525
+
526
+
527
+ def compare(a, b):
528
+ ra, rb = a.get("run") or {}, b.get("run") or {}
529
+ diffs = []
530
+ for k in ("url", "lighthouse_version", "form_factor", "throttling_method", "rtt_ms", "throughput_kbps",
531
+ "cpu_slowdown", "screen"):
532
+ if ra.get(k) != rb.get(k):
533
+ diffs.append(f"{k}: {ra.get(k)} -> {rb.get(k)}")
534
+ ma = {m["metric"]: m["value"] for m in (a.get("lab") or {}).get("metrics", [])}
535
+ mb = {m["metric"]: m["value"] for m in (b.get("lab") or {}).get("metrics", [])}
536
+ deltas = [{"metric": k, "before": ma[k], "after": mb[k], "delta": round(mb[k] - ma[k], 3)}
537
+ for k in ma if k in mb]
538
+ sa, sb = (a.get("lab") or {}).get("performance_score"), (b.get("lab") or {}).get("performance_score")
539
+ return {"comparable": not diffs, "differences": diffs, "score": [sa, sb], "deltas": deltas}
540
+
541
+
542
+ # ---------------------------------------------------------------- Markdown rendering
543
+
544
+ def fmt_v(metric, v):
545
+ if v is None:
546
+ return "-"
547
+ if metric.startswith("CLS"):
548
+ return f"{v:.2f}"
549
+ return f"{v / 1000:.2f} s" if v >= 1000 else f"{round(v)} ms"
550
+
551
+
552
+ def to_md(r, title="Lighthouse summary"):
553
+ L = [f"# {title}", ""]
554
+ run = r.get("run")
555
+ if run:
556
+ L += [f"URL: {run['url']}", f"Fetched: {run['fetch_time']} | Lighthouse {run['lighthouse_version']}",
557
+ f"Device: {run['form_factor']} {run['screen'] or ''} | Throttling: {run['throttling_method']}, "
558
+ f"{run['preset']} (RTT {run['rtt_ms']} ms, {run['throughput_kbps']} Kbps, CPU x{run['cpu_slowdown']})"
559
+ + (f" | {run['method_note']}" if run.get("method_note") else ""),
560
+ f"Host benchmarkIndex: {run['benchmark_index']}",
561
+ "This is ONE lab run: metrics vary run to run; compare medians of 3-5 runs, or field data."]
562
+ for w in run["run_warnings"]:
563
+ L.append(f"RUN WARNING: {w}")
564
+ L.append("")
565
+ L.append("## Field data (real users, p75): decides whether there is a problem")
566
+ if not r["fields"]:
567
+ L.append("None in this input. Core Web Vitals pass/fail cannot be stated from lab data.")
568
+ for f in r["fields"]:
569
+ head = f['source'] + (f", {f['scope']}-level" if f["scope"] in ("url", "origin") else "") + \
570
+ (f" ({f['id']})" if f.get("id") else "")
571
+ if f.get("form_factor"):
572
+ head += f", form factor {f['form_factor']}"
573
+ if f.get("period") and f["period"][0]:
574
+ head += f", {f['period'][0]} to {f['period'][1]}"
575
+ if f.get("origin_fallback"):
576
+ head += " | ORIGIN FALLBACK: not enough data for this URL, these are whole-origin numbers"
577
+ L += [head, "", "| Metric | p75 | Rating | Share good |", "|---|---|---|---|"]
578
+ for k in ("LCP", "INP", "CLS", "FCP", "TTFB"):
579
+ m = f["metrics"].get(k)
580
+ if m:
581
+ gs = f"{round(m['good_share'] * 100)}%" if isinstance(m.get("good_share"), (int, float)) else "-"
582
+ L.append(f"| {k} | {fmt_v(k, m['p75'])} | {m['rating']} | {gs} |")
583
+ L += ["", f"Core Web Vitals (field): {f['core_web_vitals']}",
584
+ "Thresholds at p75: LCP <= 2.5 s good, > 4 s poor; INP <= 200 ms, > 500 ms; CLS <= 0.1, > 0.25.", ""]
585
+ lab = r.get("lab")
586
+ if lab:
587
+ L += ["## Lab metrics (this run): explains why", "",
588
+ f"Performance score: {lab['performance_score']} (a lab weighting, not a Core Web Vitals result)", "",
589
+ "| Metric | Value | Lab rating | Threshold |", "|---|---|---|---|"]
590
+ for m in lab["metrics"]:
591
+ L.append(f"| {m['metric']} | {fmt_v(m['metric'], m['value'])} | {m['rating'] or '-'} | {m['threshold'] or '-'} |")
592
+ L += ["", "TBT is a lab proxy for INP, not INP. A navigation run has no interactions.", ""]
593
+ lcp = r.get("lcp")
594
+ if lcp:
595
+ L.append("## LCP element")
596
+ el = lcp["element"]
597
+ if el:
598
+ L.append(f"Selector: {el.get('selector')} | Label: {el.get('nodeLabel')}")
599
+ L.append(f"Snippet: {el.get('snippet')}")
600
+ else:
601
+ L.append("Not reported in this run.")
602
+ flags = []
603
+ if lcp["lazy_loaded"] is not None:
604
+ flags.append("LAZY-LOADED (loading=lazy on the LCP element)" if lcp["lazy_loaded"] else "not lazy-loaded")
605
+ if lcp.get("element_tag") and lcp["element_tag"] != "img" and not lcp["lcp_resource_url"]:
606
+ flags.append(f"<{lcp['element_tag']}> element: text or CSS background, no <img> to prioritise")
607
+ elif lcp["fetchpriority_high"] is not None:
608
+ flags.append("fetchpriority=high present" if lcp["fetchpriority_high"] else "no fetchpriority=high")
609
+ if lcp["lcp_resource_url"]:
610
+ flags.append(f"resource {lcp['lcp_resource_url']}")
611
+ if lcp["prioritize_savings_ms"]:
612
+ flags.append(f"preload/prioritise est. LCP saving {fmt_v('LCP', lcp['prioritize_savings_ms'])}")
613
+ if flags:
614
+ L.append("Flags: " + "; ".join(flags))
615
+ for c in lcp["discovery_checks"]:
616
+ L.append(f"- {'pass' if c['passed'] else 'FAIL'}: {c['check']}")
617
+ if lcp["phases"]:
618
+ L += ["", "| Phase | Time | Share |", "|---|---|---|"]
619
+ for p in lcp["phases"]:
620
+ L.append(f"| {p['phase']} | {fmt_v('LCP', p['ms'])} | {p['share']} |")
621
+ L.append(f"Dominant phase: {lcp['dominant_phase']}, which points to: {lcp['dominant_phase_points_to']}")
622
+ L.append("")
623
+ cls = r.get("cls")
624
+ if cls and (cls["shifts"] or cls["unsized_images"] or cls["fonts_without_display"]):
625
+ L.append("## Layout shift culprits (load window only; shifts after load are not in a lab run)")
626
+ for s in cls["shifts"]:
627
+ el = s["element"] or {}
628
+ L.append(f"- {el.get('selector') or el.get('nodeLabel')}: {s['score']:.3f}"
629
+ + (f", cause: {'; '.join(s['causes'])}" if s["causes"] else ""))
630
+ for u in cls["unsized_images"]:
631
+ L.append(f"- unsized image: {u['url']}")
632
+ for f in cls["fonts_without_display"]:
633
+ L.append(f"- font without font-display: {f['url']}")
634
+ L.append("")
635
+ ops = r.get("opportunities")
636
+ if ops:
637
+ L += ["## Failing opportunities and diagnostics, ranked by the vital they move",
638
+ "Savings are lab estimates for this run; overlapping savings do not add."
639
+ + (" Rows marked * move a vital that fails in the field data." if any(
640
+ o.get("moves_failing_field_vital") for o in ops) else ""), "",
641
+ "| Audit | Est. load saving | Metric savings | Note |", "|---|---|---|---|"]
642
+ for o in ops:
643
+ ms = ", ".join(f"{k} {fmt_v(k, v)}" for k, v in o["metric_savings"].items()) or "none"
644
+ sav = []
645
+ if o["overall_savings_ms"]:
646
+ sav.append(fmt_v("x", o["overall_savings_ms"]))
647
+ if o["overall_savings_bytes"]:
648
+ sav.append(f"{round(o['overall_savings_bytes'] / 1024)} KiB")
649
+ note = o.get("note", "")
650
+ if o.get("overlaps_with"):
651
+ note = (note + "; " if note else "") + "overlaps " + ", ".join(o["overlaps_with"])
652
+ star = "* " if o.get("moves_failing_field_vital") else ""
653
+ L.append(f"| {star}{o['id']}: {o['title']} | {' / '.join(sav) or '-'} | {ms} | {note} |")
654
+ L.append("")
655
+ mt = r.get("main_thread")
656
+ if mt and (mt["long_tasks"] or mt["third_parties"] or mt["script_bootup"]):
657
+ L.append("## Main thread (lab TBT; field INP is the real check)")
658
+ if mt["main_thread_total_ms"] is not None:
659
+ L.append(f"Main-thread work: {fmt_v('x', mt['main_thread_total_ms'])}")
660
+ if mt["long_tasks"]:
661
+ L += ["", "Long tasks:", "| Script | By | Duration | Start |", "|---|---|---|---|"]
662
+ for t in mt["long_tasks"]:
663
+ L.append(f"| {t['url']} | {t['by']} | {t['duration_ms']} ms | {t['start_ms']} ms |")
664
+ L.append("Long tasks by origin: " + "; ".join(
665
+ f"{o['by']} {o['count']} task{'s' if o['count'] != 1 else ''} / {o['total_ms']} ms" for o in mt["long_tasks_by_origin"]))
666
+ if mt["third_parties"]:
667
+ L += ["", "Third parties:", "| Entity | Main thread | Blocking | Transfer |", "|---|---|---|---|"]
668
+ for t in mt["third_parties"]:
669
+ b = f"{t['blocking_ms']} ms" if t["blocking_ms"] is not None else "-"
670
+ L.append(f"| {t['entity']} | {t['main_thread_ms']} ms | {b} | {t['transfer_kb']} KiB |")
671
+ if mt["script_bootup"]:
672
+ L += ["", "Script execution:", "| Script | By | Total | Scripting |", "|---|---|---|---|"]
673
+ for s in mt["script_bootup"]:
674
+ L.append(f"| {s['url']} | {s['by']} | {s['total_ms']} ms | {s['scripting_ms']} ms |")
675
+ L.append("")
676
+ rb = r.get("render_blocking")
677
+ if rb:
678
+ L += ["## Render-blocking resources", "| URL | Size | Est. wasted |", "|---|---|---|"]
679
+ for x in rb:
680
+ L.append(f"| {x['url']} | {x['kb']} KiB | {x['wasted_ms']} ms |")
681
+ L.append("")
682
+ return "\n".join(L)
683
+
684
+
685
+ def cmp_md(c):
686
+ L = ["# Comparison of the two runs", ""]
687
+ if c["comparable"]:
688
+ L.append("Same URL, Lighthouse version, device and throttling.")
689
+ else:
690
+ L.append("NOT COMPARABLE: the runs differ in " + "; ".join(c["differences"]))
691
+ L += [f"Performance score: {c['score'][0]} -> {c['score'][1]}", "", "| Metric | Before | After | Delta |",
692
+ "|---|---|---|---|"]
693
+ for d in c["deltas"]:
694
+ L.append(f"| {d['metric']} | {d['before']} | {d['after']} | {d['delta']:+} |")
695
+ L += ["", "Two single runs: a difference is not an improvement until it holds across the median of 3-5 runs "
696
+ "per side with identical settings, or shows up in field data."]
697
+ return "\n".join(L)
698
+
699
+
700
+ def main():
701
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
702
+ ap.add_argument("reports", nargs="+", help="Lighthouse JSON, PSI JSON or CrUX JSON path, or - for stdin (max 2)")
703
+ ap.add_argument("--field", help="extra field data: a PSI response or CrUX API record JSON")
704
+ ap.add_argument("--lcp-p75", type=float, help="field LCP p75 in ms (e.g. from RUM)")
705
+ ap.add_argument("--inp-p75", type=float, help="field INP p75 in ms")
706
+ ap.add_argument("--cls-p75", type=float, help="field CLS p75 (unitless)")
707
+ ap.add_argument("--field-source", default="provided (RUM or other)", help="label for --*-p75 numbers")
708
+ ap.add_argument("--top", type=int, default=10, help="rows per list (default 10)")
709
+ ap.add_argument("--json", action="store_true", help="print JSON instead of Markdown")
710
+ ap.add_argument("-o", "--out", help="write here instead of stdout")
711
+ args = ap.parse_args()
712
+ if len(args.reports) > 2:
713
+ die("pass one report, or two to compare")
714
+
715
+ extra = []
716
+ if args.field:
717
+ _, extra = classify(load(args.field), args.field)
718
+ mf = manual_field(args)
719
+ if mf:
720
+ extra.append(mf)
721
+
722
+ results = []
723
+ for path in args.reports:
724
+ lhr, fields = classify(load(path), path)
725
+ results.append(summarise(lhr, fields + extra, args.top))
726
+
727
+ if len(results) == 2:
728
+ c = compare(results[0], results[1])
729
+ payload = {"before": results[0], "after": results[1], "comparison": c}
730
+ text = json.dumps(payload, indent=2) if args.json else "\n\n".join(
731
+ [cmp_md(c), to_md(results[0], "Run 1: " + args.reports[0]), to_md(results[1], "Run 2: " + args.reports[1])])
732
+ else:
733
+ text = json.dumps(results[0], indent=2) if args.json else to_md(results[0])
734
+
735
+ if args.out:
736
+ with open(args.out, "w", encoding="utf-8") as fh:
737
+ fh.write(text + "\n")
738
+ else:
739
+ print(text)
740
+
741
+
742
+ if __name__ == "__main__":
743
+ main()