skilldrop-cli 0.16.35 → 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 +2 -1
  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 +253 -3
  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,113 @@
1
+ # event-contract-draft reference
2
+
3
+ House defaults for event contracts. Deviate when the input forces it and log the deviation in
4
+ the decisions page with its reason.
5
+
6
+ ## Envelope
7
+
8
+ Every message carries these fields, all `required`, in the payload (structured mode, works on
9
+ every broker) or in broker headers (binary mode). Match the casing the producer already uses.
10
+
11
+ | Role | Default name | Type | CloudEvents 1.0 | Why it exists |
12
+ |---|---|---|---|---|
13
+ | Event id | `eventId` | string, `uuid` | `id` | Consumers deduplicate on it. Same value on every redelivery and replay; a new value per fact. |
14
+ | Event type | `eventType` | string, `const` per message | `type` | Routing and dispatch. One value per schema. |
15
+ | Event version | `eventVersion` | integer ≥ 1 | `dataschema` or a `type` suffix | Consumers pick the schema; breaking changes bump it. |
16
+ | Occurred at | `occurredAt` | string, `date-time`, UTC with `Z` | `time` | When the fact happened, not when it was published. |
17
+ | Producer | `producer` | string | `source` | The service that owns the fact. |
18
+ | Correlation id | `correlationId` | string | extension | Ties every event and request in one business flow together. |
19
+ | Causation id | `causationId` | string | extension | The `eventId` or request id that directly caused this event. Optional but expected. |
20
+
21
+ Domain fields sit under `data`. Each entity carries a **revision** (`orderRevision`: +1 on every
22
+ change to that entity) so a consumer can drop a stale or out-of-order event by comparing it with
23
+ the last revision it applied.
24
+
25
+ ## Notification or event-carried state
26
+
27
+ | | Notification | Event-carried state |
28
+ |---|---|---|
29
+ | Carries | ids, the fact, a link or resource path | the fields the named consumers act on |
30
+ | Pick when | data is sensitive, changes fast, or consumers rarely need detail | consumers act without calling back, or the producer cannot take the call-back load |
31
+ | Cost | every consumer calls back; the call can see a newer state than the event | payload grows; every field is a field to version and to justify |
32
+
33
+ Event-carried state carries what named consumers need, not the whole aggregate.
34
+
35
+ ## Safe and breaking changes
36
+
37
+ | Change | Within a version? |
38
+ |---|---|
39
+ | Add an optional field | ✅ safe — consumers ignore unknown fields |
40
+ | Add a new event type to a channel | ✅ safe if consumers skip types they don't handle (state it in the contract) |
41
+ | Add an enum value | ✅ only if the enum was declared open; ❌ otherwise |
42
+ | Rename a field | ❌ breaking — add the new field beside the old one, deprecate the old one |
43
+ | Change a field's type or unit (`42.50` → `4250`) | ❌ breaking — new field with a new name (`totalMinor`) |
44
+ | Change a field's meaning under the same name | ❌ breaking, and no validator catches it |
45
+ | Make an optional field required, or remove a field | ❌ breaking |
46
+ | Change the partition key | ❌ breaking — ordering guarantees change for every consumer |
47
+ | Change partition count (Kafka) | ❌ for ordering — keys remap to new partitions; plan a new topic or a drained cut-over |
48
+
49
+ Breaking-change routes, cheapest first:
50
+ 1. **Additive shadow**: add the new field next to the old one, populate both, mark the old one
51
+ `deprecated` with `x-sunset`, remove it in the next version.
52
+ 2. **New version**: publish `vN+1` on a new channel (`orders.order-lifecycle.v2`) alongside `vN`
53
+ for the dual-publish window, mark the `vN` messages `x-deprecated` with `x-sunset` and
54
+ `x-replaced-by`. The window ends on a date the consumers agree to, not one the producer picks
55
+ alone.
56
+
57
+ ## Broker facts that change the contract
58
+
59
+ | Broker | Ordering | Delivery | Dedup help | Retention / replay | Dead letter |
60
+ |---|---|---|---|---|---|
61
+ | Kafka | per partition; key → partition by hash | at-least-once to consumers; idempotent producer and transactions stop producer duplicates inside Kafka only | none on the consumer side | `retention.ms` per topic (7 days unless set); consumers reset offsets to replay; compacted topics keep the latest per key | none built in; the consumer writes to a DLQ topic |
62
+ | SQS standard | best effort, none promised | at-least-once | none | 1 minute to 14 days (4 days default); no replay after delete | redrive policy, `maxReceiveCount` |
63
+ | SQS/SNS FIFO | per `MessageGroupId` | exactly-once processing within a 5-minute dedup interval | `MessageDeduplicationId` | as SQS | redrive policy |
64
+ | EventBridge | none | at-least-once; retries up to 24 hours by default | none | archive and replay must be switched on | per-target SQS DLQ |
65
+ | Google Pub/Sub | per ordering key, only when the subscription enables it | at-least-once; exactly-once delivery is an opt-in per subscription | `messageId` | seek to a time or snapshot inside the retention window | dead-letter topic, max delivery attempts |
66
+ | RabbitMQ | per queue, single consumer, until a redelivery | at-least-once with manual acks | none | gone once acked (streams excepted) | dead-letter exchange |
67
+ | Webhooks | none | at-least-once with retries | event id in the body | the producer's retry window and an event-list endpoint for catch-up | the producer's failed-delivery log |
68
+
69
+ Webhooks also need: an HMAC signature header over the raw body, a timestamp in the signed
70
+ content with a skew window to reject replays, the retry schedule with its cap, and "respond 2xx
71
+ fast, process asynchronously".
72
+
73
+ ## Consumer idempotency patterns
74
+
75
+ - **Dedup table**: record `eventId` in the same transaction as the effect; skip if present. Keep
76
+ ids at least as long as the retention window, or a replay re-applies everything.
77
+ - **Revision check**: upsert keyed on the entity id, applied only when `revision` is higher than
78
+ the stored one. Handles duplicates and out-of-order arrival in one rule.
79
+ - **Natural key on the effect**: award points keyed on `(orderId, reason)`, so a second award
80
+ for the same order is a no-op.
81
+ - **Side effects outside the database** (emails, partner calls): dedup record first, effect
82
+ second, and accept that a crash between them means one retry of the effect.
83
+
84
+ ## Extensions the checker reads
85
+
86
+ | Where | Key | Content |
87
+ |---|---|---|
88
+ | channel | `x-partition-key` | the field the broker keys on |
89
+ | channel | `x-ordering` | what order the key gives, and that there is no order across keys |
90
+ | channel | `x-delivery` | `at-least-once` plus where duplicates come from |
91
+ | channel | `x-retention` | how long messages are kept |
92
+ | channel | `x-replay` | whether and how a consumer re-reads |
93
+ | channel | `x-dead-letter` | what goes to the DLQ, after how many attempts, and who watches it |
94
+ | message | `x-event-style` | `notification` or `event-carried-state` |
95
+ | message | `x-deprecated`, `x-sunset`, `x-replaced-by` | deprecation state, end date, replacement message |
96
+ | property | `x-pii` | the consumer that needs this personal data |
97
+
98
+ ## Decisions page
99
+
100
+ ```markdown
101
+ ## Decisions
102
+ | # | Decision | Choice | Why |
103
+ |---|---|---|---|
104
+ | 1 | Event names | OrderPlaced, OrderPaid, OrderCancelled | Facts in the order domain; the warehouse decides what to do with them |
105
+ | 2 | Channel and key | one channel per entity lifecycle, keyed by orderId | Events whose relative order matters share a key |
106
+ | … | | | |
107
+
108
+ ## What consumers must do
109
+ 1. Deduplicate on eventId …
110
+
111
+ ## To confirm
112
+ - … (who can answer)
113
+ ```
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env python3
2
+ """Check an AsyncAPI 3.0 event contract (JSON) for the rules a reviewer checks by eye.
3
+
4
+ Reads one AsyncAPI 3.0 document as JSON (AsyncAPI accepts JSON as-is; the
5
+ standard library has no YAML parser, so emit the contract as JSON) and reports:
6
+
7
+ structure asyncapi is 3.x, info.title / info.version present, channels present,
8
+ every local $ref resolves, operations point at a channel
9
+ naming every message name is a fact in the past tense (OrderPlaced), not a
10
+ command (CreateShipment) or a generic noun (OrderEvent)
11
+ envelope every message carries, in its headers or payload, required fields for
12
+ event id, event type, event version, occurred-at (format date-time),
13
+ producer and correlation id; causation id is a warning
14
+ one type an event-type field with more than one allowed value is a failure
15
+ privacy a field that looks like personal data must carry an x-pii marker;
16
+ card numbers fail outright
17
+ evolution additionalProperties: false is a warning (consumers must be able to
18
+ ignore unknown fields); a deprecated message needs x-replaced-by and
19
+ an x-sunset date; an older version left undeprecated is a warning;
20
+ a deprecated message's own naming/envelope/privacy faults are warnings,
21
+ because it documents what is live today
22
+ channels every channel names its key (x-partition-key or a description), what
23
+ ordering that key gives (x-ordering), delivery semantics (x-delivery),
24
+ retention (x-retention) and dead-letter handling (x-dead-letter);
25
+ replay (x-replay) is a warning; a global-ordering promise is a failure
26
+
27
+ Usage:
28
+ python3 check_asyncapi.py asyncapi.json
29
+ python3 check_asyncapi.py asyncapi.json -o check-report.txt
30
+ cat asyncapi.json | python3 check_asyncapi.py -
31
+
32
+ Exit codes: 0 no failures (warnings allowed), 1 failures found, 2 unreadable input.
33
+ """
34
+
35
+ import argparse
36
+ import json
37
+ import re
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ # Envelope roles and the field names accepted for each (camelCase, snake_case, CloudEvents).
42
+ ENVELOPE = {
43
+ "event id": ["eventId", "event_id", "id"],
44
+ "event type": ["eventType", "event_type", "type"],
45
+ "event version": ["eventVersion", "event_version", "schemaVersion", "schema_version",
46
+ "version", "dataschema"],
47
+ "occurred-at": ["occurredAt", "occurred_at", "time"],
48
+ "producer": ["producer", "source"],
49
+ "correlation id": ["correlationId", "correlation_id"],
50
+ }
51
+ CAUSATION = ["causationId", "causation_id"]
52
+ ENVELOPE_CONTAINERS = {"metadata", "meta", "envelope", "header", "headers"}
53
+
54
+ COMMAND_VERBS = {
55
+ "add", "approve", "calculate", "cancel", "change", "create", "delete", "do", "emit",
56
+ "execute", "generate", "get", "handle", "make", "notify", "pay", "place", "process",
57
+ "publish", "refund", "register", "reject", "remove", "request", "run", "send", "set",
58
+ "ship", "start", "submit", "sync", "trigger", "update", "upsert",
59
+ }
60
+ GENERIC_SUFFIXES = {
61
+ "event", "events", "message", "msg", "command", "cmd", "request", "notification",
62
+ "data", "payload", "info", "update", "updates", "change", "changes",
63
+ }
64
+ IRREGULAR_PAST = {
65
+ "begun", "bought", "broken", "built", "caught", "chosen", "cut", "done", "drawn",
66
+ "found", "frozen", "given", "held", "hidden", "kept", "known", "laid", "left", "lost",
67
+ "made", "met", "paid", "put", "quit", "read", "reset", "rewritten", "run", "seen",
68
+ "sent", "set", "shown", "shut", "sold", "split", "spent", "stolen", "struck", "taken",
69
+ "thrown", "told", "undone", "upheld", "withdrawn", "won", "written",
70
+ }
71
+ PARTICLES = {"up", "out", "in", "off", "over", "back", "down", "on", "away"}
72
+
73
+ PII_EXACT = {
74
+ "email", "emailaddress", "phone", "phonenumber", "mobile", "mobilenumber", "firstname",
75
+ "lastname", "surname", "givenname", "fullname", "customername", "recipientname",
76
+ "address", "streetaddress", "deliveryaddress", "shippingaddress", "billingaddress",
77
+ "homeaddress", "postcode", "zipcode", "postalcode", "dateofbirth", "dob", "birthdate",
78
+ "ipaddress", "ip", "nationalid", "ssn", "nino", "passportnumber", "taxid", "iban",
79
+ "accountnumber",
80
+ }
81
+ PII_SUFFIXES = ("email", "phone", "phonenumber", "address", "dateofbirth")
82
+ CARD_FIELDS = {"cardnumber", "pan", "primaryaccountnumber", "cvv", "cvc", "securitycode"}
83
+
84
+ VERSION_SUFFIX = re.compile(r"[._-]?v(\d+)$", re.IGNORECASE)
85
+ ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}")
86
+ KEY_TEXT = re.compile(r"\b(partition|ordering|message[- ]group|routing)[- ]key\b|\bkeyed (by|on)\b",
87
+ re.IGNORECASE)
88
+ ORDER_TEXT = re.compile(r"\border(ed|ing)\b", re.IGNORECASE)
89
+ GLOBAL_ORDER = re.compile(r"\b(global(ly)?|total(ly)?|strict(ly)?)\s+(order|ordered|ordering)\b",
90
+ re.IGNORECASE)
91
+ NEGATION = re.compile(r"\b(no|not|never|without|isn't|doesn't|cannot|can't)\b[^.;]{0,40}"
92
+ r"\b(global|total|strict)", re.IGNORECASE)
93
+ DELIVERY_TEXT = re.compile(r"\b(at[- ]least[- ]once|at[- ]most[- ]once|exactly[- ]once)\b",
94
+ re.IGNORECASE)
95
+ EXACTLY_ONCE = re.compile(r"\bexactly[- ]once\b", re.IGNORECASE)
96
+ RETENTION_TEXT = re.compile(r"\b(retention|retained|retain|kept for)\b", re.IGNORECASE)
97
+ REPLAY_TEXT = re.compile(r"\b(replay|re-?read|rewind|seek)\b", re.IGNORECASE)
98
+ DLQ_TEXT = re.compile(r"\b(dead[- ]letter|dlq|dlx)\b", re.IGNORECASE)
99
+
100
+
101
+ class Report:
102
+ def __init__(self):
103
+ self.fails = []
104
+ self.warns = []
105
+ self.notes = []
106
+
107
+ def fail(self, where, msg):
108
+ self.fails.append(f"FAIL {where}: {msg}")
109
+
110
+ def warn(self, where, msg):
111
+ self.warns.append(f"WARN {where}: {msg}")
112
+
113
+
114
+ def load(path):
115
+ try:
116
+ text = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8")
117
+ except OSError as exc:
118
+ raise SystemExit(f"error: cannot read {path}: {exc.strerror}")
119
+ stripped = text.lstrip()
120
+ if not stripped:
121
+ raise SystemExit(f"error: {path} is empty")
122
+ if stripped[0] not in "{[":
123
+ raise SystemExit(f"error: {path} is not JSON. This checker reads JSON only; emit the "
124
+ "AsyncAPI document as JSON (AsyncAPI accepts JSON as-is).")
125
+ try:
126
+ doc = json.loads(text)
127
+ except json.JSONDecodeError as exc:
128
+ raise SystemExit(f"error: {path} is not valid JSON: {exc.msg} at line {exc.lineno}, "
129
+ f"column {exc.colno}")
130
+ if not isinstance(doc, dict):
131
+ raise SystemExit(f"error: {path} must hold a JSON object at the top level")
132
+ return doc
133
+
134
+
135
+ def pointer(doc, ref):
136
+ """Resolve a local JSON pointer like #/components/messages/OrderPlaced; None if missing."""
137
+ if ref == "#":
138
+ return doc
139
+ if not ref.startswith("#/"):
140
+ return None
141
+ node = doc
142
+ for part in ref[2:].split("/"):
143
+ part = part.replace("~1", "/").replace("~0", "~")
144
+ if isinstance(node, dict) and part in node:
145
+ node = node[part]
146
+ elif isinstance(node, list) and part.isdigit() and int(part) < len(node):
147
+ node = node[int(part)]
148
+ else:
149
+ return None
150
+ return node
151
+
152
+
153
+ def deref(doc, node, depth=0):
154
+ while isinstance(node, dict) and "$ref" in node and depth < 32:
155
+ target = pointer(doc, node["$ref"]) if isinstance(node["$ref"], str) else None
156
+ if target is None:
157
+ return {}
158
+ node, depth = target, depth + 1
159
+ return node if isinstance(node, dict) else {}
160
+
161
+
162
+ def check_refs(doc, node, rep, path="#"):
163
+ if isinstance(node, dict):
164
+ ref = node.get("$ref")
165
+ if isinstance(ref, str):
166
+ if not ref.startswith("#"):
167
+ rep.warn(path, f"external $ref {ref} not checked; inline it or check it separately")
168
+ elif pointer(doc, ref) is None:
169
+ rep.fail(path, f"$ref {ref} does not resolve")
170
+ for key, value in node.items():
171
+ check_refs(doc, value, rep, f"{path}/{key}")
172
+ elif isinstance(node, list):
173
+ for i, value in enumerate(node):
174
+ check_refs(doc, value, rep, f"{path}/{i}")
175
+
176
+
177
+ def flatten(doc, schema, depth=0):
178
+ """Return (properties, required) for a schema, merging allOf members."""
179
+ schema = deref(doc, schema)
180
+ props = dict(schema.get("properties") or {})
181
+ required = set(schema.get("required") or [])
182
+ if depth < 8:
183
+ for part in schema.get("allOf") or []:
184
+ sub_props, sub_req = flatten(doc, part, depth + 1)
185
+ for name, sub in sub_props.items():
186
+ props.setdefault(name, sub)
187
+ required |= sub_req
188
+ return props, required
189
+
190
+
191
+ def words(name):
192
+ base = VERSION_SUFFIX.sub("", name)
193
+ base = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", base)
194
+ return [w.lower() for w in re.split(r"[\s._\-:/]+", base) if w]
195
+
196
+
197
+ def check_name(name, where, rep):
198
+ parts = words(name)
199
+ if not parts:
200
+ rep.fail(where, "message has no name")
201
+ return
202
+ last = parts[-1]
203
+ if last in PARTICLES and len(parts) > 1:
204
+ last = parts[-2]
205
+ if last.endswith("ed") or last in IRREGULAR_PAST:
206
+ return
207
+ if parts[0] in COMMAND_VERBS and len(parts) > 1:
208
+ rep.fail(where, f"'{name}' reads as a command ({parts[0].title()} ...); name the fact "
209
+ "that happened, in the past tense, in the producer's domain "
210
+ "(e.g. OrderPaid, not CreateShipment)")
211
+ elif parts[-1] in GENERIC_SUFFIXES:
212
+ rep.fail(where, f"'{name}' ends in a generic noun ('{parts[-1]}'); one event type per "
213
+ "message, named as a past-tense fact (OrderPlaced, not OrderEvent)")
214
+ else:
215
+ rep.fail(where, f"'{name}' is not past tense; name the fact that happened "
216
+ "(OrderPlaced, PaymentCaptured)")
217
+
218
+
219
+ def find_field(props, aliases):
220
+ for alias in aliases:
221
+ if alias in props:
222
+ return alias
223
+ return None
224
+
225
+
226
+ def envelope_sources(doc, message):
227
+ """Yield (label, properties, required) for each schema an envelope field can live in."""
228
+ sources = []
229
+ for label in ("headers", "payload"):
230
+ if label in message:
231
+ props, req = flatten(doc, message[label])
232
+ sources.append((label, props, req))
233
+ for cname, csub in props.items():
234
+ if cname.lower() in ENVELOPE_CONTAINERS:
235
+ cprops, creq = flatten(doc, csub)
236
+ sources.append((f"{label}.{cname}", cprops, creq))
237
+ return sources
238
+
239
+
240
+ def check_envelope(doc, message, where, rep):
241
+ sources = envelope_sources(doc, message)
242
+ if not any(label == "payload" for label, _, _ in sources):
243
+ rep.fail(where, "message has no payload schema")
244
+ missing = []
245
+ for role, aliases in ENVELOPE.items():
246
+ hit = None
247
+ for label, props, req in sources:
248
+ field = find_field(props, aliases)
249
+ if field:
250
+ hit = (label, field, props[field], req)
251
+ break
252
+ if hit is None:
253
+ if role == "correlation id" and "correlationId" in message:
254
+ continue
255
+ missing.append(f"{role} ({aliases[0]})")
256
+ continue
257
+ label, field, schema, req = hit
258
+ if field not in req:
259
+ rep.fail(where, f"{role} field {label}.{field} is not in 'required'")
260
+ schema = deref(doc, schema)
261
+ if role == "occurred-at" and schema.get("format") != "date-time":
262
+ rep.fail(where, f"{label}.{field} needs format: date-time (UTC, RFC 3339)")
263
+ if role == "event type":
264
+ enum = schema.get("enum")
265
+ if isinstance(enum, list) and len(enum) > 1:
266
+ rep.fail(where, f"{label}.{field} allows {len(enum)} event types; one event "
267
+ "type per message schema")
268
+ if missing:
269
+ rep.fail(where, "envelope fields missing from headers and payload: " + ", ".join(missing))
270
+ if not any(find_field(props, CAUSATION) for _, props, _ in sources):
271
+ rep.warn(where, "no causation id (causationId); consumers cannot tell which event "
272
+ "caused this one")
273
+
274
+
275
+ def walk_props(doc, schema, path, depth=0):
276
+ """Yield (path, name, schema) for every property, descending into objects and arrays."""
277
+ if depth > 12:
278
+ return
279
+ schema = deref(doc, schema)
280
+ props, _ = flatten(doc, schema)
281
+ for name, sub in props.items():
282
+ sub_r = deref(doc, sub)
283
+ yield f"{path}.{name}", name, sub_r
284
+ yield from walk_props(doc, sub_r, f"{path}.{name}", depth + 1)
285
+ items = schema.get("items")
286
+ if isinstance(items, dict):
287
+ yield from walk_props(doc, items, f"{path}[]", depth + 1)
288
+ for key in ("oneOf", "anyOf"):
289
+ for i, part in enumerate(schema.get(key) or []):
290
+ yield from walk_props(doc, part, f"{path}({key}{i})", depth + 1)
291
+
292
+
293
+ def check_privacy_and_openness(doc, message, where, rep):
294
+ for label in ("headers", "payload"):
295
+ if label not in message:
296
+ continue
297
+ root = deref(doc, message[label])
298
+ if root.get("additionalProperties") is False:
299
+ rep.warn(f"{where}.{label}", "additionalProperties: false; a validating consumer "
300
+ "then rejects every additive change. Leave the schema open")
301
+ marked = []
302
+ for path, name, sub in walk_props(doc, message[label], label):
303
+ if any(path.startswith(m + ".") or path.startswith(m + "[") for m in marked):
304
+ continue
305
+ norm = re.sub(r"[^a-z0-9]", "", name.lower())
306
+ if "x-pii" in sub and path not in marked:
307
+ marked.append(path)
308
+ if sub.get("additionalProperties") is False:
309
+ rep.warn(f"{where}.{path}", "additionalProperties: false; leave it open so "
310
+ "new optional fields are not breaking")
311
+ if norm in CARD_FIELDS:
312
+ rep.fail(f"{where}.{path}", "card data never goes on an event; send a token "
313
+ "or a reference instead")
314
+ elif norm in PII_EXACT or norm.endswith(PII_SUFFIXES):
315
+ if "x-pii" in sub:
316
+ marked.append(path)
317
+ rep.notes.append(f"PII (marked): {where}.{path}")
318
+ else:
319
+ rep.fail(f"{where}.{path}", "looks like personal data and has no x-pii "
320
+ "marker; drop it unless a named consumer needs it, and mark it "
321
+ "x-pii if it stays")
322
+
323
+
324
+ def channel_text(channel):
325
+ parts = [str(channel.get("description") or ""), str(channel.get("summary") or "")]
326
+ for key, value in channel.items():
327
+ if key.startswith("x-"):
328
+ parts.append(json.dumps(value) if not isinstance(value, str) else value)
329
+ return " ".join(parts)
330
+
331
+
332
+ def has_kafka_key(doc, channel):
333
+ for msg in (channel.get("messages") or {}).values():
334
+ m = deref(doc, msg)
335
+ if (m.get("bindings") or {}).get("kafka", {}).get("key"):
336
+ return True
337
+ return False
338
+
339
+
340
+ def check_channel(doc, name, channel, rep):
341
+ where = f"channel {name}"
342
+ text = channel_text(channel)
343
+ desc = str(channel.get("description") or "")
344
+ if not channel.get("messages"):
345
+ rep.fail(where, "lists no messages")
346
+ has_key = any(k in channel for k in ("x-partition-key", "x-ordering-key", "x-message-group-id")) \
347
+ or has_kafka_key(doc, channel) or KEY_TEXT.search(desc)
348
+ if not has_key:
349
+ rep.fail(where, "names no partition/ordering key (x-partition-key or the description)")
350
+ if "x-ordering" not in channel and not ORDER_TEXT.search(desc):
351
+ rep.fail(where, "says nothing about ordering (x-ordering): what order the key gives "
352
+ "and what it does not")
353
+ if GLOBAL_ORDER.search(text) and not NEGATION.search(text):
354
+ rep.fail(where, "promises global/total ordering; brokers order per partition key only. "
355
+ "State per-key ordering and that there is no global order")
356
+ if "x-delivery" not in channel and not DELIVERY_TEXT.search(desc):
357
+ rep.fail(where, "no delivery semantics (x-delivery: at-least-once unless proven otherwise)")
358
+ elif EXACTLY_ONCE.search(text) and not re.search(r"\bnot exactly[- ]once\b", text, re.I):
359
+ rep.warn(where, "claims exactly-once; that holds inside the broker at best, so consumers "
360
+ "still deduplicate on the event id")
361
+ if "x-retention" not in channel and not RETENTION_TEXT.search(desc):
362
+ rep.fail(where, "no retention (x-retention): how long messages are kept")
363
+ if "x-replay" not in channel and not REPLAY_TEXT.search(desc):
364
+ rep.warn(where, "no replay statement (x-replay): can a consumer re-read, and from where")
365
+ if "x-dead-letter" not in channel and not DLQ_TEXT.search(desc):
366
+ rep.fail(where, "no dead-letter handling (x-dead-letter): what goes there and who watches it")
367
+
368
+
369
+ def collect_messages(doc):
370
+ """Return list of (name, where, message) with each distinct message object once."""
371
+ seen = {}
372
+ out = []
373
+ comps = (doc.get("components") or {}).get("messages") or {}
374
+ for key, msg in comps.items():
375
+ m = deref(doc, msg)
376
+ if id(m) not in seen:
377
+ seen[id(m)] = key
378
+ out.append((m.get("name") or key, f"message {key}", m))
379
+ for cname, channel in (doc.get("channels") or {}).items():
380
+ ch = deref(doc, channel)
381
+ for key, msg in (ch.get("messages") or {}).items():
382
+ m = deref(doc, msg)
383
+ if not m:
384
+ continue # an unresolved $ref, already reported
385
+ if id(m) not in seen:
386
+ seen[id(m)] = key
387
+ out.append((m.get("name") or key, f"channel {cname} message {key}", m))
388
+ return out
389
+
390
+
391
+ def check_versions(doc, messages, rep):
392
+ names = {}
393
+ for name, where, msg in messages:
394
+ names[name] = msg
395
+ by_base = {}
396
+ for name, where, msg in messages:
397
+ match = VERSION_SUFFIX.search(name)
398
+ version = int(match.group(1)) if match else 1
399
+ by_base.setdefault(VERSION_SUFFIX.sub("", name), []).append((version, name, where, msg))
400
+ for base, versions in by_base.items():
401
+ if len(versions) < 2:
402
+ continue
403
+ versions.sort()
404
+ for version, name, where, msg in versions[:-1]:
405
+ if not (msg.get("x-deprecated") or msg.get("deprecated")):
406
+ rep.warn(where, f"{name} and a newer version of {base} both exist but {name} is "
407
+ "not marked x-deprecated with a sunset date")
408
+ for name, where, msg in messages:
409
+ if not (msg.get("x-deprecated") or msg.get("deprecated")):
410
+ continue
411
+ sunset = msg.get("x-sunset")
412
+ if not sunset:
413
+ rep.fail(where, "deprecated without x-sunset (the date the dual-publish window ends)")
414
+ elif not ISO_DATE.match(str(sunset)):
415
+ rep.warn(where, f"x-sunset is '{sunset}', not a date; agree the date and list it as "
416
+ "open until then")
417
+ repl = msg.get("x-replaced-by")
418
+ if not repl:
419
+ rep.fail(where, "deprecated without x-replaced-by (the event consumers move to)")
420
+ else:
421
+ ref = repl.get("$ref") if isinstance(repl, dict) else repl
422
+ if not (ref in names or (isinstance(ref, str) and pointer(doc, ref) is not None)):
423
+ rep.fail(where, f"x-replaced-by {ref} names no message in this document")
424
+
425
+
426
+ def check(doc):
427
+ rep = Report()
428
+ version = str(doc.get("asyncapi") or "")
429
+ if not version.startswith("3."):
430
+ rep.fail("asyncapi", f"version is '{version or 'missing'}'; this checks AsyncAPI 3.x")
431
+ info = doc.get("info") or {}
432
+ for field in ("title", "version"):
433
+ if not info.get(field):
434
+ rep.fail("info", f"missing info.{field}")
435
+ if not doc.get("defaultContentType"):
436
+ rep.warn("document", "no defaultContentType (application/json, application/cloudevents+json, ...)")
437
+ check_refs(doc, doc, rep)
438
+
439
+ channels = doc.get("channels") or {}
440
+ if not channels:
441
+ rep.fail("channels", "no channels")
442
+ for name, channel in channels.items():
443
+ check_channel(doc, name, deref(doc, channel), rep)
444
+
445
+ messages = collect_messages(doc)
446
+ if not messages:
447
+ rep.fail("messages", "no messages")
448
+ for name, where, msg in messages:
449
+ if msg.get("x-deprecated") or msg.get("deprecated"):
450
+ # A deprecated message documents what is live today; its faults are why it is
451
+ # being replaced, so they are reported as warnings, not failures.
452
+ legacy = Report()
453
+ check_name(name, where, legacy)
454
+ check_envelope(doc, msg, where, legacy)
455
+ check_privacy_and_openness(doc, msg, where, legacy)
456
+ rep.warns += [line.replace("FAIL ", "WARN (deprecated) ", 1)
457
+ for line in legacy.fails] + legacy.warns
458
+ rep.notes += legacy.notes
459
+ continue
460
+ check_name(name, where, rep)
461
+ check_envelope(doc, msg, where, rep)
462
+ check_privacy_and_openness(doc, msg, where, rep)
463
+ check_versions(doc, messages, rep)
464
+
465
+ operations = doc.get("operations") or {}
466
+ if not operations:
467
+ rep.warn("operations", "no operations; say who sends and who receives each channel")
468
+ for name, op in operations.items():
469
+ op = deref(doc, op)
470
+ where = f"operation {name}"
471
+ if op.get("action") not in ("send", "receive"):
472
+ rep.fail(where, "action must be 'send' or 'receive'")
473
+ ch = op.get("channel")
474
+ if not (isinstance(ch, dict) and isinstance(ch.get("$ref"), str)):
475
+ rep.fail(where, "channel must be a $ref to a channel")
476
+ counts = f"{len(channels)} channels, {len(messages)} messages, {len(operations)} operations"
477
+ return rep, counts
478
+
479
+
480
+ def main(argv=None):
481
+ parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
482
+ parser.add_argument("path", help="AsyncAPI 3.0 document as JSON, or - for stdin")
483
+ parser.add_argument("-o", "--output", help="write the report to this path as well as stdout")
484
+ args = parser.parse_args(argv)
485
+ try:
486
+ doc = load(args.path)
487
+ except SystemExit as exc:
488
+ print(exc, file=sys.stderr)
489
+ return 2
490
+ rep, counts = check(doc)
491
+ lines = [f"AsyncAPI check: {args.path}", f" {counts}"]
492
+ lines += rep.fails + rep.warns + rep.notes
493
+ lines.append(f"Result: {len(rep.fails)} failures, {len(rep.warns)} warnings")
494
+ text = "\n".join(lines) + "\n"
495
+ sys.stdout.write(text)
496
+ if args.output:
497
+ Path(args.output).write_text(text, encoding="utf-8")
498
+ return 1 if rep.fails else 0
499
+
500
+
501
+ if __name__ == "__main__":
502
+ sys.exit(main())