hecks 2.0.0 → 2.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8261eb6f8b8b490fda7cc10deed5d1668e677031b03d67c89ca2c706cb90140a
4
- data.tar.gz: 005ef58bad7618ab6085b22c9483465d2b58af9a9c61db7a365e70db81f4a5d6
3
+ metadata.gz: 1fad5f64ba40ddec2720f013efc33d54e7ce5ebaf0926bc0670b642f91e53416
4
+ data.tar.gz: a714cad37a3a9c02f2ece530fc2c417e019f3ac47e0f0b76fd2c336416c2d55d
5
5
  SHA512:
6
- metadata.gz: 926f57e7d5a1af30ec918269460bc04a20c25dd1feaaf8da910fe0a0ea241f7b4622feec775dfa8390bba16253b6d4b18130b0ea5a0e544259cae70cbd961cbe
7
- data.tar.gz: aca0677bd624c1a61d9f6d3dbe6f0e74991d690557d4abed0abf4a3e4a240931434a375b633ba7f424f04c3c36a2454811608ca2894a99d12cbdc2460876ca1b
6
+ metadata.gz: 3ed37d25061346166c25415b92be46409ecefa3eb762fef81941c4758ecc3a2681241f29a4c819a28ad360ee0267919612f9fedb9c56c1be00d0a9a70d512e6e
7
+ data.tar.gz: 7b6b0df13da60ef2a40a002d2a847b284164518c51363227c01f1d00e548e3d544b25a27d28053ff1af2469c6f802f4a5bd94103b6d2a9a9ed491710b9152217
@@ -8,7 +8,12 @@ module Hecks
8
8
  # complete and point at real verbs. `BluebookBuilder::Validation
9
9
  # #validate_provisions!` holds every `provides` to this table.
10
10
  #
11
- # key => :command | :query the kind of verb that key must name
11
+ # key => :command | :query | :port_operation the kind of verb that key must name
12
+ #
13
+ # A `:port_operation` verb is spelled `"Aggregate.Port.Operation"` and names
14
+ # an operation the chapter's hecksagon declares on one of its aggregates.
15
+ # The hecksagon attaches after the chapter is built, so the chapter itself
16
+ # only checks the spelling; `Registry#verify!` checks the operation exists.
12
17
  module Capabilities
13
18
  AUTHORIZATION = "authorization".freeze
14
19
  # Who may sign in, and with what role — rust/host's Google-OAuth
@@ -22,6 +27,19 @@ module Hecks
22
27
  # is. rust/host does not build this chapter's payloads; any field
23
28
  # mapping lives on the consuming hecksagon's `translates` ACL.
24
29
  IDENTITY = "identity".freeze
30
+ # Guest-facing newsletter signup: who subscribed, confirmed, or left —
31
+ # recognised by declaration, not the literal chapter name
32
+ # "Newsletter". rust/host's newsletter routes dispatch these verbs
33
+ # and read the subscribing aggregate's instances off `subscribe`.
34
+ NEWSLETTER = "newsletter".freeze
35
+ # Taking a payment through an external processor: start one, then
36
+ # hear the processor's verdict — recognised by declaration, not the
37
+ # literal chapter name "Payments". rust/host's checkout and webhook
38
+ # routes dispatch these verbs and read the paying aggregate's
39
+ # instances off `initiate`. The two verdicts are hecksagon port
40
+ # operations (the processor's webhook, translated into this chapter's
41
+ # vocabulary), not commands.
42
+ PAYMENTS = "payments".freeze
25
43
 
26
44
  CONTRACTS = {
27
45
  AUTHORIZATION => {
@@ -47,6 +65,24 @@ module Hecks
47
65
  link: :command,
48
66
  # look up the identity an authenticated pair resolves to
49
67
  resolve: :query
68
+ }.freeze,
69
+ NEWSLETTER => {
70
+ # a guest signs up; the aggregate it names is the subscriber
71
+ subscribe: :command,
72
+ # attach a display name to an existing subscriber
73
+ add_name: :command,
74
+ # a subscriber confirms their address from the emailed link
75
+ confirm: :command,
76
+ # a subscriber leaves from the emailed link
77
+ unsubscribe: :command
78
+ }.freeze,
79
+ PAYMENTS => {
80
+ # start a payment; the aggregate it names is the payment
81
+ initiate: :command,
82
+ # the processor reports the money arrived
83
+ succeeded: :port_operation,
84
+ # the processor reports the payment failed or expired
85
+ failed: :port_operation
50
86
  }.freeze
51
87
  }.freeze
52
88
  end
@@ -113,12 +113,14 @@ module Hecks
113
113
  # @param capability [String] the capability the row belongs to, such as
114
114
  # `"authorization"`
115
115
  # @param row [Bluebook::Chapter::Provision] the one declared row to check
116
- # @param kind [Symbol] `:command` or `:query`, the kind of verb this row's key
117
- # must name (from `Capabilities::CONTRACTS`)
116
+ # @param kind [Symbol] `:command`, `:query` or `:port_operation`, the kind of
117
+ # verb this row's key must name (from `Capabilities::CONTRACTS`)
118
118
  # @return [void]
119
119
  # @raise [Bluebook::DSL::Malformed] if `row.verb` does not split into a real
120
120
  # aggregate and a member of the right kind that aggregate declares
121
121
  def validate_provided_verb!(bluebook, capability, row, kind)
122
+ return validate_provided_port_operation!(bluebook, capability, row) if kind == :port_operation
123
+
122
124
  aggregate_name, member = row.verb.split(".", 2)
123
125
  aggregate = bluebook.aggregate(aggregate_name)
124
126
  return if aggregate && member && provided_member_names(aggregate, kind).include?(member)
@@ -127,6 +129,26 @@ module Hecks
127
129
  "which names no #{kind} this chapter declares (spelled \"Aggregate.#{kind.capitalize}\")"
128
130
  end
129
131
 
132
+ # Checks the spelling of a `provides` verb that names a hecksagon port
133
+ # operation. The port itself is declared in the hecksagon, which attaches
134
+ # after this chapter is built, so all that can be checked here is that the
135
+ # verb has the `"Aggregate.Port.Operation"` shape and its aggregate is
136
+ # this chapter's; `Registry#verify!` checks the operation exists.
137
+ #
138
+ # @param bluebook [Bluebook::Chapter] the chapter the row was declared on
139
+ # @param capability [String] the capability the row belongs to
140
+ # @param row [Bluebook::Chapter::Provision] the one declared row to check
141
+ # @return [void]
142
+ # @raise [Bluebook::DSL::Malformed] if the verb is not `"Aggregate.Port.Operation"`
143
+ # or names an aggregate this chapter does not declare
144
+ def validate_provided_port_operation!(bluebook, capability, row)
145
+ aggregate_name, port, operation = row.verb.split(".", 3)
146
+ return if bluebook.aggregate(aggregate_name) && port && operation && !operation.include?(".")
147
+
148
+ raise Malformed, "#{bluebook.name} provides #{capability.inspect} #{row.key}: #{row.verb.inspect}, " \
149
+ "which is not spelled \"Aggregate.Port.Operation\" over an aggregate this chapter declares"
150
+ end
151
+
130
152
  # Names of the members a `provides` verb of the given kind may address.
131
153
  #
132
154
  # @param aggregate [Bluebook::Aggregate] the aggregate the verb is scoped to
data/lib/hecks/corpus.rb CHANGED
@@ -284,7 +284,7 @@ module Hecks
284
284
  # lifeadelics) — the generated module and Cargo feature are
285
285
  # "lifeadelics" now, matching every other domain's own convention.
286
286
  #
287
- # Lifeadelics also attaches `accounts`/`newsletter`/`payments`
287
+ # Lifeadelics also attaches `newsletter`/`payments`/`membership`
288
288
  # (its own vendored embryonaut_bluebooks packages, see
289
289
  # RUST_EXTERNAL_VENDORED_CHAPTERS below) and `Privacy` (an in-repo
290
290
  # framework chapter, lib/hecks/framework/bluebook/privacy.bluebook,
@@ -312,7 +312,6 @@ module Hecks
312
312
  # same as an in-repo vendored chapter (see
313
313
  # `rust_external_vendored_domain_dir`, below).
314
314
  RUST_EXTERNAL_VENDORED_CHAPTERS = {
315
- "accounts" => "lifeadelics",
316
315
  "newsletter" => "lifeadelics",
317
316
  "payments" => "lifeadelics",
318
317
  "membership" => "lifeadelics"
@@ -365,14 +364,12 @@ module Hecks
365
364
  # still fail, so an entry that starts passing breaks the build until
366
365
  # it is deleted here.
367
366
  RUST_COVERAGE_PENDING = {
368
- "accounts" => "query Listing declares no where clause at all (\"every account, alphabetically by " \
369
- "email\") — rust/project/queries.rb's own no_wheres skip refuses to generate an " \
370
- "unfiltered per_instance Listing (\"nothing for filter_entries to bake in\"); a " \
371
- "structural Rust codegen limitation, not a bug in this vendored package",
372
- "newsletter" => "same no_wheres gap as accounts, on all 3 of its own Listing queries " \
373
- "(Delivery/Issue/Subscriber) — see accounts' entry above",
374
- "lifeadelics" => "same no_wheres gap as accounts, on Registration.Listing — see accounts' entry above",
375
- "membership" => "same no_wheres gap as accounts, on Person.All — see accounts' entry above"
367
+ "newsletter" => "query Listing declares no where clause at all, on all 3 of its own Listing queries " \
368
+ "(Delivery/Issue/Subscriber) — rust/project/queries.rb's own no_wheres skip refuses " \
369
+ "to generate an unfiltered per_instance Listing (\"nothing for filter_entries to " \
370
+ "bake in\"); a structural Rust codegen limitation, not a bug in this vendored package",
371
+ "lifeadelics" => "same no_wheres gap as newsletter, on Registration.Listing — see newsletter's entry above",
372
+ "membership" => "same no_wheres gap as newsletter, on Person.All — see newsletter's entry above"
376
373
  }.freeze
377
374
 
378
375
  # The Cargo `[features]` table's raw text.
@@ -296,6 +296,13 @@ Hecks.bluebook "Bluebook", version: "1" do
296
296
  member keyword: "provides", context: "Bluebook", at: "", named: "register", kind: "text", required: "false", fills: "provides"
297
297
  member keyword: "provides", context: "Bluebook", at: "", named: "link", kind: "text", required: "false", fills: "provides"
298
298
  member keyword: "provides", context: "Bluebook", at: "", named: "resolve", kind: "text", required: "false", fills: "provides"
299
+ member keyword: "provides", context: "Bluebook", at: "", named: "subscribe", kind: "text", required: "false", fills: "provides"
300
+ member keyword: "provides", context: "Bluebook", at: "", named: "add_name", kind: "text", required: "false", fills: "provides"
301
+ member keyword: "provides", context: "Bluebook", at: "", named: "confirm", kind: "text", required: "false", fills: "provides"
302
+ member keyword: "provides", context: "Bluebook", at: "", named: "unsubscribe", kind: "text", required: "false", fills: "provides"
303
+ member keyword: "provides", context: "Bluebook", at: "", named: "initiate", kind: "text", required: "false", fills: "provides"
304
+ member keyword: "provides", context: "Bluebook", at: "", named: "succeeded", kind: "text", required: "false", fills: "provides"
305
+ member keyword: "provides", context: "Bluebook", at: "", named: "failed", kind: "text", required: "false", fills: "provides"
299
306
  member keyword: "aggregate", context: "Bluebook", at: "1", named: "", kind: "text", required: "true", fills: "name"
300
307
  member keyword: "read_model", context: "Bluebook", at: "1", named: "", kind: "text", required: "true", fills: "name"
301
308
  member keyword: "policy", context: "Bluebook", at: "1", named: "", kind: "text", required: "true", fills: "name"
@@ -174,6 +174,60 @@ module Hecks
174
174
  }
175
175
  end
176
176
 
177
+ # Same seam as `membership` — which chapter answers the guest
178
+ # newsletter signup (`Registry#newsletter_provider_for`), with its
179
+ # declared verbs qualified and the subscribing aggregate named off
180
+ # `subscribe`. rust/host reads this instead of naming
181
+ # Newsletter::Subscriber.Subscribe and friends. `{}` when nothing
182
+ # this domain attaches provides newsletter.
183
+ # @param registry [Runtime::Registry] the booted registry `domain_name` is loaded in
184
+ # @param domain_name [String] the domain to export the newsletter binding for
185
+ # @return [Hash{Symbol => String, nil}] `:provider` (name), `:subscribe`, `:add_name`,
186
+ # `:confirm`, `:unsubscribe` (qualified verbs), and `:aggregate` (`:subscribe`'s own
187
+ # leading qualified aggregate name); `{}` if nothing this domain attaches provides
188
+ # newsletter
189
+ def newsletter(registry, domain_name)
190
+ provider = registry.newsletter_provider_for(domain_name)
191
+ return {} unless provider
192
+
193
+ capability = Bluebook::Capabilities::NEWSLETTER
194
+ subscribe = provider.provided_verb(capability, :subscribe)
195
+ {
196
+ provider: provider.name,
197
+ subscribe: subscribe,
198
+ add_name: provider.provided_verb(capability, :add_name),
199
+ confirm: provider.provided_verb(capability, :confirm),
200
+ unsubscribe: provider.provided_verb(capability, :unsubscribe),
201
+ aggregate: subscribe&.split(".")&.first
202
+ }
203
+ end
204
+
205
+ # Same seam as `newsletter` — which chapter takes payments
206
+ # (`Registry#payments_provider_for`), with its declared verbs
207
+ # qualified and the paying aggregate named off `initiate`. rust/host
208
+ # reads this instead of naming Payments::Payment.Initiate and the
209
+ # PaymentGateway operations. `{}` when nothing this domain attaches
210
+ # provides payments.
211
+ # @param registry [Runtime::Registry] the booted registry `domain_name` is loaded in
212
+ # @param domain_name [String] the domain to export the payments binding for
213
+ # @return [Hash{Symbol => String, nil}] `:provider` (name), `:initiate`, `:succeeded`,
214
+ # `:failed` (qualified verbs), and `:aggregate` (`:initiate`'s own leading qualified
215
+ # aggregate name); `{}` if nothing this domain attaches provides payments
216
+ def payments(registry, domain_name)
217
+ provider = registry.payments_provider_for(domain_name)
218
+ return {} unless provider
219
+
220
+ capability = Bluebook::Capabilities::PAYMENTS
221
+ initiate = provider.provided_verb(capability, :initiate)
222
+ {
223
+ provider: provider.name,
224
+ initiate: initiate,
225
+ succeeded: provider.provided_verb(capability, :succeeded),
226
+ failed: provider.provided_verb(capability, :failed),
227
+ aggregate: initiate&.split(".")&.first
228
+ }
229
+ end
230
+
177
231
  # Translation IR, always as an array, with each aggregate's
178
232
  # precompiled SQL attached (`compiled_translation_aggregate`) —
179
233
  # this is the export a consumer embeds (`ir.json`'s `translations`
@@ -24,6 +24,7 @@ module Hecks
24
24
  verify_singleton_port_answers!
25
25
  refuse_cross_package_bluebook_merge!
26
26
  refuse_membership_without_identity!
27
+ refuse_unresolved_port_operations!
27
28
 
28
29
  @hecksagons.each_value do |hexagon|
29
30
  refuse_ungoverned_roles!(hexagon)
@@ -330,6 +331,37 @@ module Hecks
330
331
  "identity verbs are exported onto ir.json, not guessed at deploy."
331
332
  end
332
333
 
334
+ # A `provides` row whose contract kind is `:port_operation` names an
335
+ # operation the chapter's hecksagon declares, and the hecksagon
336
+ # attaches after the chapter is built, so this is the first point the
337
+ # operation can be checked. Without it, a host reading the declared
338
+ # verb would dispatch to an operation nothing declares.
339
+ def refuse_unresolved_port_operations!
340
+ @bluebooks.each_value do |chapter|
341
+ chapter.provides.each do |row|
342
+ next unless Bluebook::Capabilities::CONTRACTS.dig(row.capability, row.key.to_sym) == :port_operation
343
+ next if port_operation_declared?(chapter, row.verb)
344
+
345
+ raise WiringError,
346
+ "#{chapter.name} provides #{row.capability.inspect} #{row.key}: #{row.verb.inspect}, " \
347
+ "but its hecksagon declares no such port operation — declare it with " \
348
+ "`#{chapter.name}::Aggregate.port \"Port\" do operation \"Operation\" ... end`."
349
+ end
350
+ end
351
+ end
352
+
353
+ # Says whether a chapter declares the port operation an
354
+ # `"Aggregate.Port.Operation"` verb names.
355
+ #
356
+ # @param chapter [Bluebook::Chapter] the chapter whose aggregates' ports are searched
357
+ # @param verb [String] the verb, spelled `"Aggregate.Port.Operation"`
358
+ # @return [Boolean] whether that aggregate has that port with that operation
359
+ def port_operation_declared?(chapter, verb)
360
+ aggregate_name, port_name, operation_name = verb.split(".", 3)
361
+ ports = chapter.aggregate(aggregate_name)&.ports || []
362
+ ports.any? { |port| port.name == port_name && port.operations.any? { |op| op.hecks_name == operation_name } }
363
+ end
364
+
333
365
  def refuse_bounded_without_acl!(hexagon)
334
366
  return unless hexagon.bounded?
335
367
  return if hexagon.translates.any?
@@ -375,20 +375,59 @@ module Hecks
375
375
  # @return [Bluebook::Chapter, nil] the chapter that answers `domain`'s membership
376
376
  # questions, or nil if none does
377
377
  def membership_provider_for(domain)
378
+ vendored_provider_for(domain, Bluebook::Capabilities::MEMBERSHIP)
379
+ end
380
+
381
+ # The chapter that answers the guest newsletter signup for `domain` —
382
+ # the domain's own chapter, a framework member, or a vendored
383
+ # embryonaut bluebook it attaches, that declares `provides
384
+ # "newsletter"`. Nil when none does. Newsletter is recognised by
385
+ # what it declares (Subscribe/AddName/Confirm/Unsubscribe), so a
386
+ # chapter that declares the same thing is recognised the same way.
387
+ #
388
+ # @param domain [String, Symbol] the domain whose newsletter chapter is being resolved
389
+ # @return [Bluebook::Chapter, nil] the chapter that answers `domain`'s newsletter
390
+ # signup, or nil if none does
391
+ def newsletter_provider_for(domain)
392
+ vendored_provider_for(domain, Bluebook::Capabilities::NEWSLETTER)
393
+ end
394
+
395
+ # The chapter that takes payments for `domain` — the domain's own
396
+ # chapter, a framework member, or a vendored embryonaut bluebook it
397
+ # attaches, that declares `provides "payments"`. Nil when none does.
398
+ # Payments is recognised by what it declares (Initiate and the
399
+ # processor's two verdicts), so a chapter that declares the same
400
+ # thing is recognised the same way.
401
+ #
402
+ # @param domain [String, Symbol] the domain whose payments chapter is being resolved
403
+ # @return [Bluebook::Chapter, nil] the chapter that takes `domain`'s payments, or nil
404
+ # if none does
405
+ def payments_provider_for(domain)
406
+ vendored_provider_for(domain, Bluebook::Capabilities::PAYMENTS)
407
+ end
408
+
409
+ # The chapter that provides `capability` for `domain`: the domain's
410
+ # own chapter, any framework member its hecksagon attaches, or any
411
+ # vendored embryonaut bluebook it attaches, that declares it. Falls
412
+ # back to any loaded chapter that does, because a consuming domain
413
+ # often wires a vendored chapter as its own `Hecks.hecksagon "Name"`
414
+ # (so that chapter can attach Governance on that named hexagon)
415
+ # rather than via `uses_embryonaut_bluebook` on itself — the chapter
416
+ # is loaded, it just isn't listed on the consumer's own hexagon.
417
+ #
418
+ # @param domain [String, Symbol] the domain the provider is resolved for
419
+ # @param capability [String] the capability's name, such as
420
+ # `Bluebook::Capabilities::MEMBERSHIP`
421
+ # @return [Bluebook::Chapter, nil] the providing chapter, or nil if none loaded does
422
+ def vendored_provider_for(domain, capability)
378
423
  hexagon = hecksagon(domain)
379
424
  vendored = Array(hexagon&.vendored_bluebooks).map { |name| Naming.pascal(name) }
380
425
  names = [domain.to_s, *Array(hexagon&.framework_members), *vendored]
381
426
  attached = names.filter_map { |name| bluebook(name) }
382
- .find { |chapter| chapter.provides?(Bluebook::Capabilities::MEMBERSHIP) }
427
+ .find { |chapter| chapter.provides?(capability) }
383
428
  return attached if attached
384
429
 
385
- # Sibling hecksagons — Lifeadelics vendors Membership as
386
- # `Hecks.hecksagon "Membership"` (so Person::Admit can attach
387
- # Governance on that named hexagon), not via uses_embryonaut_bluebook
388
- # on the consuming domain. The chapter is loaded; it just isn't
389
- # listed on Lifeadelics' own hexagon. Any loaded chapter that
390
- # provides membership is the bounded-context answer.
391
- @bluebooks.values.find { |chapter| chapter.provides?(Bluebook::Capabilities::MEMBERSHIP) }
430
+ @bluebooks.values.find { |chapter| chapter.provides?(capability) }
392
431
  end
393
432
 
394
433
  # Resolves and memoizes `aggregate`'s authoritative repository.
data/lib/hecks/version.rb CHANGED
@@ -12,5 +12,5 @@ module Hecks
12
12
  # it there, or even in the consuming Gemfile, never closed the gap,
13
13
  # because gemspec evaluation happens before anything Bundler
14
14
  # resolves is actually loadable yet.
15
- VERSION = "2.0.0".freeze
15
+ VERSION = "2.2.0".freeze
16
16
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-24 00:00:00.000000000 Z
11
+ date: 2026-09-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism