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.
- package/README.md +1 -0
- package/agents/README.md +4 -1
- package/agents/docs-reviewer.md +47 -0
- package/agents/finding-adjudicator.md +49 -0
- package/agents/test-reviewer.md +52 -0
- package/catalogue.json +17 -7
- package/model-routing.json +40 -0
- package/package.json +1 -1
- package/packs/ai-engineering/skills/agent-config-review/SKILL.md +116 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/eval_queries.json +12 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/evals.json +75 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/db-migrator.md +8 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/reviewer.md +9 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.json +48 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.local.json +5 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.mcp.json +22 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/package.json +12 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/2/.cursor/mcp.json +17 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/2/config.toml +21 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/my-user-settings.json +11 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/agents/release-notes.md +13 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/settings.json +39 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.mcp.json +18 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/package.json +14 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/5/.github/workflows/codex-triage.yml +29 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/5/AGENTS.md +16 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/5/codex/config.toml +34 -0
- package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.claude/settings.json +22 -0
- package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.mcp.json +9 -0
- package/packs/ai-engineering/skills/agent-config-review/examples/python-repo-tighten.md +81 -0
- package/packs/ai-engineering/skills/agent-config-review/manifest.json +60 -0
- package/packs/ai-engineering/skills/agent-config-review/reference.md +205 -0
- package/packs/ai-engineering/skills/agent-config-review/scripts/agent_config_scan.py +989 -0
- package/packs/ai-engineering/skills/agent-incident-response/SKILL.md +175 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/eval_queries.json +14 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/evals.json +107 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/agent-session-excerpt.txt +11 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/ci-build-log.txt +24 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/incident-notes.md +19 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/mcp-config.json +20 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/session-transcript.txt +8 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/agent-session.log +70 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/infra/staging-db.tf +25 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/job.yaml +19 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/run.log +13 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/usage-export-2026-10-20.csv +13 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/.mcp.json +20 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/git-log.txt +12 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync-excerpt.txt +30 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync.log +15 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/ssh-config.txt +16 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/.github/workflows/ci.yml +28 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/SECURITY.md +8 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/issue-479.md +35 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/package.json +15 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/pr-482.diff +20 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/run-log.txt +30 -0
- package/packs/ai-engineering/skills/agent-incident-response/examples/injected-issue-comment.md +57 -0
- package/packs/ai-engineering/skills/agent-incident-response/manifest.json +55 -0
- package/packs/ai-engineering/skills/agent-incident-response/reference.md +152 -0
- package/packs/ai-engineering/skills/agent-incident-response/scripts/find_secret_exposure.py +239 -0
- package/packs/ai-engineering/skills/agent-incident-response/scripts/usage_split.py +194 -0
- package/packs/converters/skills/file-to-markdown/SKILL.md +2 -1
- package/packs/converters/skills/file-to-markdown/evals/eval_queries.json +4 -0
- package/packs/converters/skills/file-to-markdown/manifest.json +2 -2
- package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +253 -3
- package/packs/converters/skills/md-to-confluence/SKILL.md +139 -0
- package/packs/converters/skills/md-to-confluence/evals/eval_queries.json +42 -0
- package/packs/converters/skills/md-to-confluence/evals/evals.json +89 -0
- package/packs/converters/skills/md-to-confluence/evals/files/1/img/latency-dashboard.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/1/payments-runbook.md +60 -0
- package/packs/converters/skills/md-to-confluence/evals/files/2/deploy-checklist.md +33 -0
- package/packs/converters/skills/md-to-confluence/evals/files/3/img/after/screen.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/3/img/before/screen.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/3/release-notes-4.2.md +49 -0
- package/packs/converters/skills/md-to-confluence/evals/files/4/docs/img/topology.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/4/docs/ingest-pipeline-design.md +65 -0
- package/packs/converters/skills/md-to-confluence/evals/files/5/support/troubleshooting-sso.md +57 -0
- package/packs/converters/skills/md-to-confluence/examples/inputs/img/queue-depth.png +0 -0
- package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.fixed.md +21 -0
- package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.md +21 -0
- package/packs/converters/skills/md-to-confluence/examples/queue-runbook.md +57 -0
- package/packs/converters/skills/md-to-confluence/manifest.json +52 -0
- package/packs/converters/skills/md-to-confluence/reference.md +92 -0
- package/packs/converters/skills/md-to-confluence/scripts/md_to_confluence.py +821 -0
- package/packs/converters/skills/md-to-pptx/SKILL.md +131 -0
- package/packs/converters/skills/md-to-pptx/evals/eval_queries.json +12 -0
- package/packs/converters/skills/md-to-pptx/evals/evals.json +84 -0
- package/packs/converters/skills/md-to-pptx/evals/files/1/img/defect-burndown.png +0 -0
- package/packs/converters/skills/md-to-pptx/evals/files/1/status-report.md +75 -0
- package/packs/converters/skills/md-to-pptx/evals/files/3/brand.json +12 -0
- package/packs/converters/skills/md-to-pptx/evals/files/3/night-shift-induction.md +53 -0
- package/packs/converters/skills/md-to-pptx/evals/files/4/charts/ontime.png +0 -0
- package/packs/converters/skills/md-to-pptx/evals/files/4/qbr-q3.md +85 -0
- package/packs/converters/skills/md-to-pptx/evals/files/5/vendor-sync-notes.md +28 -0
- package/packs/converters/skills/md-to-pptx/examples/support-weekly-input.md +48 -0
- package/packs/converters/skills/md-to-pptx/examples/support-weekly.md +75 -0
- package/packs/converters/skills/md-to-pptx/examples/tickets-by-week.png +0 -0
- package/packs/converters/skills/md-to-pptx/manifest.json +49 -0
- package/packs/converters/skills/md-to-pptx/reference.md +139 -0
- package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1841 -0
- package/packs/dev-team/loops/modernize/LOOP.md +124 -0
- package/packs/dev-team/loops/modernize/evals/eval_queries.json +9 -0
- package/packs/dev-team/loops/modernize/evals/evals.json +27 -0
- package/packs/dev-team/loops/modernize/loop.json +60 -0
- package/packs/dev-team/skills/behaviour-equivalence/SKILL.md +155 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/eval_queries.json +11 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/evals.json +76 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/cases.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/new.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/notes.md +15 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old_rerun.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v1.py +45 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v2.py +65 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/rules.json +10 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/3/diff-summary.txt +16 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/diff.txt +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py2.py +16 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py3.py +24 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/input.csv +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/new_output.csv +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/old_output.csv +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/TEAM_NOTES.md +19 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/normalisation.yaml +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/new_run.jsonl +8 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run1.jsonl +8 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run2.jsonl +8 -0
- package/packs/dev-team/skills/behaviour-equivalence/examples/delivery-date.md +78 -0
- package/packs/dev-team/skills/behaviour-equivalence/manifest.json +33 -0
- package/packs/dev-team/skills/behaviour-equivalence/reference.md +97 -0
- package/packs/dev-team/skills/behaviour-equivalence/scripts/golden_diff.py +524 -0
- package/packs/dev-team/skills/behaviour-equivalence/templates/accepted.json +10 -0
- package/packs/dev-team/skills/behaviour-equivalence/templates/rules.json +7 -0
- package/packs/frontend/pack.json +55 -0
- package/packs/frontend/skills/rendering-strategy/SKILL.md +149 -0
- package/packs/frontend/skills/rendering-strategy/evals/eval_queries.json +38 -0
- package/packs/frontend/skills/rendering-strategy/evals/evals.json +77 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/1/fernhill-app.md +40 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/3/routes.md +33 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/build-notes.txt +5 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/nuxt.config.ts +19 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/pages/recipes/[slug].vue +37 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/routes.md +15 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/routes.txt +18 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/src/hooks.server.ts +14 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/(marketing)/pricing/+page.server.ts +10 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/+layout.server.ts +10 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/svelte.config.js +10 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/traffic.md +18 -0
- package/packs/frontend/skills/rendering-strategy/examples/saas-site.md +42 -0
- package/packs/frontend/skills/rendering-strategy/manifest.json +50 -0
- package/packs/frontend/skills/rendering-strategy/reference.md +116 -0
- package/packs/frontend/skills/rendering-strategy/scripts/render_load.py +181 -0
- package/packs/frontend/skills/web-performance-review/SKILL.md +106 -0
- package/packs/frontend/skills/web-performance-review/evals/eval_queries.json +38 -0
- package/packs/frontend/skills/web-performance-review/evals/evals.json +76 -0
- package/packs/frontend/skills/web-performance-review/evals/files/1/psi.json +1065 -0
- package/packs/frontend/skills/web-performance-review/evals/files/2/after.json +359 -0
- package/packs/frontend/skills/web-performance-review/evals/files/2/before.json +375 -0
- package/packs/frontend/skills/web-performance-review/evals/files/4/psi-pdp-mobile-2026-10-19.json +171 -0
- package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-attribution-oct14-19.csv +6 -0
- package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-daily.csv +15 -0
- package/packs/frontend/skills/web-performance-review/evals/files/5/crux-home-phone.json +35 -0
- package/packs/frontend/skills/web-performance-review/evals/files/5/lighthouse-home-mobile.json +173 -0
- package/packs/frontend/skills/web-performance-review/examples/quayside-article-lhr.json +480 -0
- package/packs/frontend/skills/web-performance-review/examples/quayside-article.md +103 -0
- package/packs/frontend/skills/web-performance-review/manifest.json +48 -0
- package/packs/frontend/skills/web-performance-review/reference.md +105 -0
- package/packs/frontend/skills/web-performance-review/scripts/lh_summary.py +743 -0
- package/packs/product-manager/skills/lean-canvas/SKILL.md +162 -0
- package/packs/product-manager/skills/lean-canvas/evals/eval_queries.json +42 -0
- package/packs/product-manager/skills/lean-canvas/evals/evals.json +82 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/1/pawpass-notes.md +54 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/3/lunchloop-pitch.md +30 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/4/clubkit-notes.md +44 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/5/cost-model.csv +5 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/5/proposal.md +40 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/5/stakeholder-emails.txt +18 -0
- package/packs/product-manager/skills/lean-canvas/examples/bike-repair.md +72 -0
- package/packs/product-manager/skills/lean-canvas/manifest.json +34 -0
- package/packs/product-manager/skills/lean-canvas/reference.md +102 -0
- package/packs/product-manager/skills/lean-canvas/scripts/unit_economics.py +211 -0
- package/packs/solution-architect/skills/api-contract-draft/SKILL.md +2 -2
- package/packs/solution-architect/skills/api-contract-draft/manifest.json +28 -6
- package/packs/solution-architect/skills/event-contract-draft/SKILL.md +167 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/eval_queries.json +38 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/evals.json +76 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/1/order-events-brief.md +45 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/4/NOTES.md +23 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/4/schemas/shipment-update.json +40 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/5/invoice.example.json +25 -0
- package/packs/solution-architect/skills/event-contract-draft/examples/order-service.asyncapi.json +246 -0
- package/packs/solution-architect/skills/event-contract-draft/examples/order-service.md +92 -0
- package/packs/solution-architect/skills/event-contract-draft/manifest.json +25 -0
- package/packs/solution-architect/skills/event-contract-draft/reference.md +113 -0
- package/packs/solution-architect/skills/event-contract-draft/scripts/check_asyncapi.py +502 -0
- package/packs/solution-architect/skills/event-contract-draft/templates/asyncapi-skeleton.json +144 -0
- package/packs/sre-oncall/skills/slo-spec/SKILL.md +161 -0
- package/packs/sre-oncall/skills/slo-spec/evals/eval_queries.json +38 -0
- package/packs/sre-oncall/skills/slo-spec/evals/evals.json +75 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/1/order-service.md +51 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/3/search-slo-status.md +30 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/4/alerts/payments-api.rules.yaml +16 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/4/docs/dependencies.md +8 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/4/metrics/summary-90d.md +51 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/5/ops-notes.md +34 -0
- package/packs/sre-oncall/skills/slo-spec/examples/ticket-booking.md +88 -0
- package/packs/sre-oncall/skills/slo-spec/manifest.json +34 -0
- package/packs/sre-oncall/skills/slo-spec/reference.md +113 -0
- package/packs/sre-oncall/skills/slo-spec/scripts/slo_math.py +449 -0
- 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())
|