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,524 @@
1
+ #!/usr/bin/env python3
2
+ """Compare recorded outputs of an old and a new system, case by case (golden master).
3
+
4
+ Each side is either
5
+ - a directory: one file per case, the case id is the file name without its
6
+ extension; *.json files are parsed as JSON, any other file is compared as
7
+ text; or
8
+ - a JSONL file: one JSON object per line, the case id is the value of --key
9
+ (default "case_id").
10
+
11
+ A rules file normalises known noise before comparing. Each rule names one
12
+ field path, one rule and a reason:
13
+
14
+ {"rules": [
15
+ {"path": "body.quoted_at", "rule": "ignore", "reason": "wall-clock time"},
16
+ {"path": "body.quote_id", "rule": "regex", "pattern": "^Q-[0-9a-f]{8}$",
17
+ "replace": "Q-<id>", "reason": "random per call"},
18
+ {"path": "body.lines", "rule": "sort", "by": "sku", "reason": "set order"},
19
+ {"path": "body.fx_rate", "rule": "tolerance", "abs": "0.000001",
20
+ "reason": "float printed by a different library"},
21
+ {"path": "body.score", "rule": "round", "digits": 3, "reason": "..."}
22
+ ]}
23
+
24
+ A plain object {"<path>": "ignore" | {"rule": ...}} is accepted too.
25
+
26
+ Paths: dotted keys; `[]` is every element of a list, `[2]` one element, `*` any
27
+ key at that level, `$` the whole record (for text files). There is no
28
+ any-depth wildcard on purpose: every rule names where it applies.
29
+
30
+ Rules:
31
+ ignore the value becomes "<ignored>"; a field present on one side only
32
+ still counts as a difference
33
+ regex re.sub(pattern, replace) on string values (replace defaults to "<norm>")
34
+ sort sorts the list, by the element key "by" or by the whole element
35
+ tolerance numbers within "abs" and/or "rel" compare equal (applied while comparing)
36
+ round rounds numbers to "digits" places, "mode" half_even (default) or half_up
37
+ Order: ignore, regex and round in file order, then sort, then compare.
38
+
39
+ --accepted FILE lists intended changes, each with an approver:
40
+ {"accepted": [{"path": "body.message", "reason": "...", "approver": "...",
41
+ "cases": ["c07"]}]}
42
+ A difference at that path (in those cases, or every case when "cases" is
43
+ absent) is reported as accepted instead of failing the comparison.
44
+
45
+ Usage:
46
+ python3 golden_diff.py OLD NEW [--rules rules.json] [--accepted accepted.json]
47
+ [--key case_id] [--max-diffs 20] [--json] [-o report]
48
+
49
+ Exit codes: 0 equivalent after rules (accepted changes allowed),
50
+ 1 not equivalent (a difference, or a case missing on one side),
51
+ 2 bad input.
52
+ """
53
+
54
+ import argparse
55
+ import json
56
+ import re
57
+ import sys
58
+ from collections import OrderedDict
59
+ from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal, InvalidOperation
60
+ from pathlib import Path
61
+
62
+ RULES = ("ignore", "regex", "sort", "tolerance", "round")
63
+ MONEY_WORDS = ("price", "amount", "total", "cost", "fee", "tax", "vat", "minor",
64
+ "cents", "pence", "balance", "charge", "subtotal", "money")
65
+ IGNORED = "<ignored>"
66
+ ABSENT = object()
67
+
68
+
69
+ class BadInput(Exception):
70
+ pass
71
+
72
+
73
+ # ---------------------------------------------------------------- paths
74
+
75
+ TOKEN = re.compile(r"\[(\d*)\]|([^.\[\]]+)")
76
+
77
+
78
+ def parse_path(text):
79
+ text = text.strip()
80
+ if text in ("$", ""):
81
+ return []
82
+ if text.startswith("$."):
83
+ text = text[2:]
84
+ out, pos = [], 0
85
+ while pos < len(text):
86
+ if text[pos] == ".":
87
+ pos += 1
88
+ continue
89
+ m = TOKEN.match(text, pos)
90
+ if not m:
91
+ raise BadInput("cannot read path: " + text)
92
+ if m.group(0).startswith("["):
93
+ out.append(("idx", int(m.group(1))) if m.group(1) else ("any_idx", None))
94
+ elif m.group(2) == "*":
95
+ out.append(("any_key", None))
96
+ else:
97
+ out.append(("key", m.group(2)))
98
+ pos = m.end()
99
+ return out
100
+
101
+
102
+ def matches(pattern, concrete):
103
+ if len(pattern) != len(concrete):
104
+ return False
105
+ for (kind, val), step in zip(pattern, concrete):
106
+ if kind == "key" and not (isinstance(step, str) and step == val):
107
+ return False
108
+ if kind == "any_key" and not isinstance(step, str):
109
+ return False
110
+ if kind == "idx" and not (isinstance(step, int) and step == val):
111
+ return False
112
+ if kind == "any_idx" and not isinstance(step, int):
113
+ return False
114
+ return True
115
+
116
+
117
+ def show(concrete):
118
+ out = ""
119
+ for step in concrete:
120
+ out += "[{}]".format(step) if isinstance(step, int) else ("." if out else "") + step
121
+ return out or "$"
122
+
123
+
124
+ def generalise(concrete):
125
+ return show([0 if isinstance(s, int) else s for s in concrete]).replace("[0]", "[]")
126
+
127
+
128
+ # ---------------------------------------------------------------- loading
129
+
130
+ def num(value, what):
131
+ try:
132
+ return Decimal(str(value))
133
+ except (InvalidOperation, ValueError):
134
+ raise BadInput("{} must be a number, got {!r}".format(what, value))
135
+
136
+
137
+ def load_rules(path):
138
+ try:
139
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
140
+ except (OSError, ValueError) as exc:
141
+ raise BadInput("cannot read rules file {}: {}".format(path, exc))
142
+ if isinstance(raw, dict) and "rules" in raw:
143
+ items = raw["rules"]
144
+ elif isinstance(raw, dict):
145
+ items = []
146
+ for p, spec in raw.items():
147
+ spec = {"rule": spec} if isinstance(spec, str) else dict(spec)
148
+ spec["path"] = p
149
+ items.append(spec)
150
+ else:
151
+ items = raw
152
+ if not isinstance(items, list):
153
+ raise BadInput("rules file must hold a list of rules")
154
+ rules = []
155
+ for i, r in enumerate(items, 1):
156
+ if not isinstance(r, dict) or "path" not in r or "rule" not in r:
157
+ raise BadInput("rule {} needs a path and a rule".format(i))
158
+ if r["rule"] not in RULES:
159
+ raise BadInput("rule {}: unknown rule {!r} (use one of {})".format(i, r["rule"], ", ".join(RULES)))
160
+ rule = {"n": i, "path": r["path"], "pat": parse_path(r["path"]), "rule": r["rule"],
161
+ "reason": str(r.get("reason", "")).strip(), "hits": 0, "subtree": False}
162
+ if r["rule"] == "regex":
163
+ try:
164
+ rule["re"] = re.compile(r["pattern"])
165
+ except (KeyError, re.error) as exc:
166
+ raise BadInput("rule {}: bad or missing pattern: {}".format(i, exc))
167
+ rule["replace"] = r.get("replace", "<norm>")
168
+ elif r["rule"] == "tolerance":
169
+ if "abs" not in r and "rel" not in r:
170
+ raise BadInput("rule {}: tolerance needs abs or rel".format(i))
171
+ rule["abs"] = num(r.get("abs", 0), "rule {} abs".format(i))
172
+ rule["rel"] = num(r.get("rel", 0), "rule {} rel".format(i))
173
+ elif r["rule"] == "round":
174
+ if not isinstance(r.get("digits"), int):
175
+ raise BadInput("rule {}: round needs integer digits".format(i))
176
+ rule["digits"] = r["digits"]
177
+ mode = r.get("mode", "half_even")
178
+ if mode not in ("half_even", "half_up"):
179
+ raise BadInput("rule {}: mode must be half_even or half_up".format(i))
180
+ rule["mode"] = ROUND_HALF_EVEN if mode == "half_even" else ROUND_HALF_UP
181
+ elif r["rule"] == "sort":
182
+ rule["by"] = r.get("by")
183
+ rules.append(rule)
184
+ return rules
185
+
186
+
187
+ def load_accepted(path):
188
+ try:
189
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
190
+ except (OSError, ValueError) as exc:
191
+ raise BadInput("cannot read accepted-changes file {}: {}".format(path, exc))
192
+ items = raw.get("accepted") if isinstance(raw, dict) else raw
193
+ if not isinstance(items, list):
194
+ raise BadInput("accepted-changes file must hold a list under \"accepted\"")
195
+ out = []
196
+ for i, a in enumerate(items, 1):
197
+ if not isinstance(a, dict) or not a.get("path"):
198
+ raise BadInput("accepted change {} needs a path".format(i))
199
+ if not str(a.get("approver", "")).strip():
200
+ raise BadInput("accepted change {} ({}) has no approver".format(i, a["path"]))
201
+ cases = a.get("cases")
202
+ out.append({"path": a["path"], "pat": parse_path(a["path"]), "approver": a["approver"],
203
+ "reason": a.get("reason", ""), "cases": set(cases) if cases else None, "hits": 0})
204
+ return out
205
+
206
+
207
+ def parse_json(text, where):
208
+ try:
209
+ return json.loads(text, parse_float=Decimal)
210
+ except ValueError as exc:
211
+ raise BadInput("{}: not valid JSON: {}".format(where, exc))
212
+
213
+
214
+ def load_side(path, key):
215
+ p = Path(path)
216
+ cases = OrderedDict()
217
+ if p.is_dir():
218
+ for f in sorted(p.iterdir()):
219
+ if f.name.startswith(".") or not f.is_file():
220
+ continue
221
+ if f.stem in cases:
222
+ raise BadInput("{}: two files for case {}".format(path, f.stem))
223
+ text = f.read_text(encoding="utf-8")
224
+ cases[f.stem] = parse_json(text, str(f)) if f.suffix == ".json" else text
225
+ elif p.is_file():
226
+ with p.open(encoding="utf-8") as fh:
227
+ for n, line in enumerate(fh, 1):
228
+ if not line.strip():
229
+ continue
230
+ rec = parse_json(line, "{} line {}".format(path, n))
231
+ if not isinstance(rec, dict) or key not in rec:
232
+ raise BadInput("{} line {}: no {!r} field".format(path, n, key))
233
+ cid = str(rec[key])
234
+ if cid in cases:
235
+ raise BadInput("{} line {}: duplicate case id {}".format(path, n, cid))
236
+ cases[cid] = rec
237
+ else:
238
+ raise BadInput("not found: " + str(path))
239
+ if not cases:
240
+ raise BadInput("no cases in " + str(path))
241
+ return cases
242
+
243
+
244
+ # ---------------------------------------------------------------- normalising
245
+
246
+ def canon(value):
247
+ return json.dumps(value, sort_keys=True, default=str, ensure_ascii=False)
248
+
249
+
250
+ def is_num(v):
251
+ return isinstance(v, (int, Decimal)) and not isinstance(v, bool)
252
+
253
+
254
+ def apply_rule(rule, value):
255
+ kind = rule["rule"]
256
+ if kind == "ignore":
257
+ if isinstance(value, (dict, list)):
258
+ rule["subtree"] = True
259
+ rule["hits"] += 1
260
+ return IGNORED
261
+ if kind == "regex" and isinstance(value, str):
262
+ rule["hits"] += 1
263
+ return rule["re"].sub(rule["replace"], value)
264
+ if kind == "round" and is_num(value):
265
+ rule["hits"] += 1
266
+ q = Decimal(1).scaleb(-rule["digits"])
267
+ return Decimal(value).quantize(q, rounding=rule["mode"])
268
+ if kind == "sort" and isinstance(value, list):
269
+ rule["hits"] += 1
270
+ by = rule.get("by")
271
+ if by:
272
+ return sorted(value, key=lambda e: canon(e.get(by) if isinstance(e, dict) else e))
273
+ return sorted(value, key=canon)
274
+ return value
275
+
276
+
277
+ def normalise(value, rules, here=()):
278
+ if isinstance(value, dict):
279
+ value = OrderedDict((k, normalise(v, rules, here + (k,))) for k, v in value.items())
280
+ elif isinstance(value, list):
281
+ value = [normalise(v, rules, here + (i,)) for i, v in enumerate(value)]
282
+ for rule in rules:
283
+ if matches(rule["pat"], list(here)):
284
+ value = apply_rule(rule, value)
285
+ return value
286
+
287
+
288
+ # ---------------------------------------------------------------- comparing
289
+
290
+ def within(a, b, rule):
291
+ a, b = Decimal(a), Decimal(b)
292
+ gap = abs(a - b)
293
+ return gap <= rule["abs"] or gap <= rule["rel"] * max(abs(a), abs(b))
294
+
295
+
296
+ def compare(old, new, tolerances, here, diffs):
297
+ tol = next((t for t in tolerances if matches(t["pat"], list(here))), None)
298
+ if is_num(old) and is_num(new):
299
+ if Decimal(old) == Decimal(new):
300
+ return
301
+ if tol and within(old, new, tol):
302
+ tol["hits"] += 1
303
+ return
304
+ diffs.append((here, "changed", old, new))
305
+ return
306
+ if isinstance(old, dict) and isinstance(new, dict):
307
+ for k in old:
308
+ if k not in new:
309
+ diffs.append((here + (k,), "missing in new", old[k], ABSENT))
310
+ else:
311
+ compare(old[k], new[k], tolerances, here + (k,), diffs)
312
+ for k in new:
313
+ if k not in old:
314
+ diffs.append((here + (k,), "added in new", ABSENT, new[k]))
315
+ return
316
+ if isinstance(old, list) and isinstance(new, list):
317
+ if len(old) != len(new):
318
+ diffs.append((here, "list length {} -> {}".format(len(old), len(new)), len(old), len(new)))
319
+ for i in range(min(len(old), len(new))):
320
+ compare(old[i], new[i], tolerances, here + (i,), diffs)
321
+ return
322
+ if type(old) is not type(new) and not (is_num(old) and is_num(new)):
323
+ diffs.append((here, "type {} -> {}".format(tname(old), tname(new)), old, new))
324
+ return
325
+ if isinstance(old, str) and ("\n" in old or "\n" in new):
326
+ a, b = old.split("\n"), new.split("\n")
327
+ for n in range(max(len(a), len(b))):
328
+ la = a[n] if n < len(a) else ABSENT
329
+ lb = b[n] if n < len(b) else ABSENT
330
+ if la != lb:
331
+ diffs.append((here, "line {} changed".format(n + 1), la, lb))
332
+ return
333
+ if old != new:
334
+ diffs.append((here, "changed", old, new))
335
+
336
+
337
+ def tname(v):
338
+ if v is None:
339
+ return "null"
340
+ if isinstance(v, bool):
341
+ return "bool"
342
+ if is_num(v):
343
+ return "number"
344
+ return {str: "string", dict: "object", list: "list"}.get(type(v), type(v).__name__)
345
+
346
+
347
+ def plain(v):
348
+ if v is ABSENT:
349
+ return "(absent)"
350
+ if isinstance(v, Decimal):
351
+ return int(v) if v == v.to_integral_value() and "." not in str(v) else float(v)
352
+ if isinstance(v, dict):
353
+ return {k: plain(x) for k, x in v.items()}
354
+ if isinstance(v, list):
355
+ return [plain(x) for x in v]
356
+ return v
357
+
358
+
359
+ def short(v, width=80):
360
+ if v is ABSENT:
361
+ return "(absent)"
362
+ text = v.replace("\n", "\\n") if isinstance(v, str) else json.dumps(plain(v), ensure_ascii=False, default=str)
363
+ return text if len(text) <= width else text[: width - 3] + "..."
364
+
365
+
366
+ def run(args):
367
+ rules = load_rules(args.rules) if args.rules else []
368
+ accepted = load_accepted(args.accepted) if args.accepted else []
369
+ old = load_side(args.old, args.key)
370
+ new = load_side(args.new, args.key)
371
+ transforms = [r for r in rules if r["rule"] in ("ignore", "regex", "round")]
372
+ sorts = [r for r in rules if r["rule"] == "sort"]
373
+ tolerances = [r for r in rules if r["rule"] == "tolerance"]
374
+
375
+ matched, mismatched, accepted_only = [], [], []
376
+ all_diffs, accepted_diffs = [], []
377
+ for cid in old:
378
+ if cid not in new:
379
+ continue
380
+ a = normalise(normalise(old[cid], transforms), sorts)
381
+ b = normalise(normalise(new[cid], transforms), sorts)
382
+ diffs = []
383
+ compare(a, b, tolerances, (), diffs)
384
+ open_diffs = []
385
+ for d in diffs:
386
+ hit = next((x for x in accepted if matches(x["pat"], list(d[0]))
387
+ and (x["cases"] is None or cid in x["cases"])), None)
388
+ if hit:
389
+ hit["hits"] += 1
390
+ accepted_diffs.append((cid,) + d + (hit,))
391
+ else:
392
+ open_diffs.append(d)
393
+ if open_diffs:
394
+ mismatched.append(cid)
395
+ all_diffs.extend((cid,) + d for d in open_diffs)
396
+ elif diffs:
397
+ accepted_only.append(cid)
398
+ else:
399
+ matched.append(cid)
400
+
401
+ missing = [c for c in old if c not in new]
402
+ extra = [c for c in new if c not in old]
403
+ fields = OrderedDict()
404
+ for cid, path, kind, _, _ in all_diffs:
405
+ f = fields.setdefault(generalise(path), {"diffs": 0, "cases": [], "kinds": []})
406
+ f["diffs"] += 1
407
+ if cid not in f["cases"]:
408
+ f["cases"].append(cid)
409
+ if kind not in f["kinds"]:
410
+ f["kinds"].append(kind)
411
+
412
+ if mismatched or missing or extra:
413
+ verdict = "NOT EQUIVALENT"
414
+ elif accepted_only:
415
+ verdict = "EQUIVALENT EXCEPT ACCEPTED CHANGES"
416
+ else:
417
+ verdict = "EQUIVALENT"
418
+
419
+ warnings = []
420
+ for r in rules:
421
+ label = "rule {} ({} {})".format(r["n"], r["rule"], r["path"])
422
+ if not r["reason"]:
423
+ warnings.append(label + " has no reason")
424
+ if r["hits"] == 0 and r["rule"] != "tolerance":
425
+ warnings.append(label + " matched nothing: check the path")
426
+ if r["subtree"]:
427
+ warnings.append(label + " ignores a whole object or list: name the leaf fields instead")
428
+ last = next((v for k, v in reversed(r["pat"]) if k == "key"), "")
429
+ if r["rule"] in ("tolerance", "round") and any(w in last.lower() for w in MONEY_WORDS):
430
+ warnings.append(label + " loosens a money-like field: compare money exactly in minor units")
431
+ if r["rule"] == "tolerance" and r["hits"]:
432
+ warnings.append("{} absorbed {} difference(s)".format(label, r["hits"]))
433
+ for a in accepted:
434
+ if a["hits"] == 0:
435
+ warnings.append("accepted change at {} matched no difference".format(a["path"]))
436
+
437
+ return {
438
+ "verdict": verdict,
439
+ "cases": {"old": len(old), "new": len(new), "compared": len(old) - len(missing),
440
+ "matched": len(matched), "mismatched": len(mismatched),
441
+ "accepted_only": len(accepted_only),
442
+ "missing_in_new": missing, "extra_in_new": extra},
443
+ "mismatched_cases": mismatched,
444
+ "fields": [{"path": p, "diffs": f["diffs"], "cases": len(f["cases"]),
445
+ "case_ids": f["cases"], "kinds": f["kinds"]} for p, f in fields.items()],
446
+ "diffs": [{"case": c, "path": show(p), "kind": k, "old": plain(o), "new": plain(n)}
447
+ for c, p, k, o, n in all_diffs[: args.max_diffs]],
448
+ "diffs_total": len(all_diffs),
449
+ "accepted": [{"case": c, "path": show(p), "old": plain(o), "new": plain(n),
450
+ "approver": h["approver"]} for c, p, _, o, n, h in accepted_diffs],
451
+ "rules": [{"n": r["n"], "path": r["path"], "rule": r["rule"], "reason": r["reason"],
452
+ "hits": r["hits"]} for r in rules],
453
+ "warnings": warnings,
454
+ }
455
+
456
+
457
+ def render(rep, max_diffs):
458
+ c = rep["cases"]
459
+ head = "Verdict after rules: " + rep["verdict"]
460
+ if rep["warnings"]:
461
+ head += " ({} warning{} at the end: read them before trusting this)".format(
462
+ len(rep["warnings"]), "" if len(rep["warnings"]) == 1 else "s")
463
+ out = [head, ""]
464
+ out.append("Cases: {} old, {} new, {} compared: {} matched, {} mismatched, {} accepted-only".format(
465
+ c["old"], c["new"], c["compared"], c["matched"], c["mismatched"], c["accepted_only"]))
466
+ if c["missing_in_new"]:
467
+ out.append("Missing in new: " + ", ".join(c["missing_in_new"]))
468
+ if c["extra_in_new"]:
469
+ out.append("Only in new: " + ", ".join(c["extra_in_new"]))
470
+ if rep["fields"]:
471
+ out += ["", "Differences by field:", " {:<40} {:>6} {:>6} {}".format("path", "diffs", "cases", "case ids")]
472
+ for f in rep["fields"]:
473
+ ids = ", ".join(f["case_ids"][:8]) + (" ..." if len(f["case_ids"]) > 8 else "")
474
+ out.append(" {:<40} {:>6} {:>6} {}".format(f["path"], f["diffs"], f["cases"], ids))
475
+ if rep["diffs"]:
476
+ out += ["", "First {} of {} differences:".format(len(rep["diffs"]), rep["diffs_total"])]
477
+ for d in rep["diffs"]:
478
+ out.append(" {} {} [{}] old={} new={}".format(
479
+ d["case"], d["path"], d["kind"], short(d["old"]), short(d["new"])))
480
+ if rep["accepted"]:
481
+ out += ["", "Accepted changes ({}):".format(len(rep["accepted"]))]
482
+ for a in rep["accepted"][:max_diffs]:
483
+ out.append(" {} {} old={} new={} approved by {}".format(
484
+ a["case"], a["path"], short(a["old"]), short(a["new"]), a["approver"]))
485
+ if rep["rules"]:
486
+ out += ["", "Rules applied:"]
487
+ for r in rep["rules"]:
488
+ out.append(" {}. {} {} ({} hit{}) {}".format(
489
+ r["n"], r["rule"], r["path"], r["hits"], "" if r["hits"] == 1 else "s",
490
+ r["reason"] or "NO REASON GIVEN"))
491
+ if rep["warnings"]:
492
+ out += ["", "Warnings:"] + [" - " + w for w in rep["warnings"]]
493
+ return "\n".join(out) + "\n"
494
+
495
+
496
+ def main(argv=None):
497
+ ap = argparse.ArgumentParser(description="Golden-master diff of old vs new recorded outputs.")
498
+ ap.add_argument("old", help="directory or JSONL file of the old system's outputs")
499
+ ap.add_argument("new", help="directory or JSONL file of the new system's outputs")
500
+ ap.add_argument("--rules", help="normalisation rules (JSON)")
501
+ ap.add_argument("--accepted", help="approved intended changes (JSON)")
502
+ ap.add_argument("--key", default="case_id", help="case id field in JSONL records (default case_id)")
503
+ ap.add_argument("--max-diffs", type=int, default=20, help="differences to list (default 20)")
504
+ ap.add_argument("--json", action="store_true", help="print the report as JSON")
505
+ ap.add_argument("-o", "--output", help="write the report to this path instead of stdout")
506
+ args = ap.parse_args(argv)
507
+ try:
508
+ rep = run(args)
509
+ except BadInput as exc:
510
+ sys.stderr.write("error: {}\n".format(exc))
511
+ return 2
512
+ except (OSError, UnicodeDecodeError) as exc:
513
+ sys.stderr.write("error: {}\n".format(exc))
514
+ return 2
515
+ text = json.dumps(rep, indent=2, ensure_ascii=False, default=str) + "\n" if args.json else render(rep, args.max_diffs)
516
+ if args.output:
517
+ Path(args.output).write_text(text, encoding="utf-8")
518
+ else:
519
+ sys.stdout.write(text)
520
+ return 0 if rep["verdict"] != "NOT EQUIVALENT" else 1
521
+
522
+
523
+ if __name__ == "__main__":
524
+ sys.exit(main())
@@ -0,0 +1,10 @@
1
+ {
2
+ "accepted": [
3
+ {
4
+ "path": "body.error.message",
5
+ "cases": ["<case id>"],
6
+ "approver": "<person or role named in the input>",
7
+ "reason": "<what changed on purpose, and where it was agreed>"
8
+ }
9
+ ]
10
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "rules": [
3
+ {"path": "body.created_at", "rule": "ignore", "reason": "wall-clock time of the call; varies old-vs-old"},
4
+ {"path": "body.id", "rule": "regex", "pattern": "^[0-9a-f-]{36}$", "replace": "<uuid>", "reason": "random UUID per call; format still checked"},
5
+ {"path": "body.items", "rule": "sort", "by": "code", "reason": "built from a hash map; no consumer reads the order"}
6
+ ]
7
+ }
@@ -0,0 +1,55 @@
1
+ {
2
+ "description": "For frontend engineers and the people who review their pages: read a Lighthouse report for what actually slows the page for real users, and choose how each route is rendered from what it needs.",
3
+ "display_name": "Frontend",
4
+ "keywords": [
5
+ "frontend",
6
+ "web-performance",
7
+ "core-web-vitals",
8
+ "bundle-size",
9
+ "rendering",
10
+ "ssr"
11
+ ],
12
+ "links": {
13
+ "documentation": "https://sananthanarayan.github.io/skilldrop/packs/frontend/"
14
+ },
15
+ "maintainers": [
16
+ {
17
+ "name": "sananthanarayan"
18
+ }
19
+ ],
20
+ "first-value": {
21
+ "audience-posture": "technical",
22
+ "prerequisites": [
23
+ "A Lighthouse JSON report for a slow page (`npx lighthouse <url> --output=json --output-path=report.json`), and its field data from PageSpeed Insights or Search Console if you have it"
24
+ ],
25
+ "starter-task": "Find out what actually makes a page slow for real users",
26
+ "starter-prompt": "Here's report.json from Lighthouse for our product page. We score 92 in the lab, but Search Console says LCP is poor on mobile. What's making it slow for real users, and what should we fix first?",
27
+ "verification": "web-performance-review leads with the field verdict (which vital fails at p75), names the LCP element and the phase that dominates it, and ranks fixes by how much each moves that vital, not by Lighthouse's estimated savings.",
28
+ "recovery": "If the reply leads with the lab score or a generic checklist, ask for it by name (\"use web-performance-review\") and include the PageSpeed Insights JSON, which carries the field data."
29
+ },
30
+ "journey": {
31
+ "when": "A page is slow, or you're deciding how a new app's routes should be rendered.",
32
+ "you_bring": [
33
+ "a Lighthouse or PageSpeed Insights JSON, with field data if you have it",
34
+ "the app's routes, how fresh each one's data must be, and the framework"
35
+ ],
36
+ "steps": [
37
+ {
38
+ "skill": "rendering-strategy",
39
+ "you_do": "Describe the routes, their data and traffic, and the framework.",
40
+ "you_get": "A route table: how each page is rendered and cached, what invalidates it, and what it shows when its data source is slow.",
41
+ "decide": "Which routes change rendering mode first."
42
+ },
43
+ {
44
+ "skill": "web-performance-review",
45
+ "you_do": "Hand over the Lighthouse or PageSpeed Insights JSON, with field data.",
46
+ "you_get": "Which vital fails for real users, why, and the fixes ranked by how much each moves it.",
47
+ "decide": "Which fix ships first, and how you'll measure it in the field."
48
+ }
49
+ ],
50
+ "you_end_with": "Pages rendered the way their data needs, and fixes chosen by what real users feel."
51
+ },
52
+ "requires": [
53
+ "core"
54
+ ]
55
+ }