hecks 1.2.0 → 1.3.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
  3. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  4. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  5. data/lib/hecks/behaviors/expectations.rb +32 -4
  6. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  7. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  8. data/lib/hecks/bluebook/model_check.rb +148 -17
  9. data/lib/hecks/forms/field_shape.rb +5 -3
  10. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  11. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  12. data/lib/hecks/fuzzing/form_census.rb +184 -0
  13. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  14. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  15. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  16. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  17. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  18. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  19. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  20. data/lib/hecks/fuzzing/properties.rb +23 -2
  21. data/lib/hecks/fuzzing/replay.rb +130 -20
  22. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  23. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  24. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  25. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  26. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  27. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  28. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  29. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  30. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  31. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  32. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  33. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  34. data/lib/hecks/fuzzing.rb +6 -0
  35. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  36. data/lib/hecks/naming.rb +70 -2
  37. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  38. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  39. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
  40. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  41. data/lib/hecks/projections/glossary/html.rb +250 -0
  42. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  43. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  44. data/lib/hecks/projections/glossary/page.css +271 -0
  45. data/lib/hecks/projections/glossary/page.js +72 -0
  46. data/lib/hecks/projections/glossary/sections.rb +17 -0
  47. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  48. data/lib/hecks/projections/glossary.rb +214 -286
  49. data/lib/hecks/projector/narrate_projector.rb +4 -11
  50. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  51. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  52. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  53. data/lib/hecks/runtime/entity_element.rb +168 -16
  54. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  55. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  56. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  57. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  58. data/lib/hecks/runtime/registry.rb +12 -0
  59. data/lib/hecks/runtime/routing.rb +67 -2
  60. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  61. data/lib/hecks/runtime/value/coercion.rb +77 -115
  62. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  63. data/lib/hecks/runtime/value.rb +7 -2
  64. data/lib/hecks/version.rb +1 -1
  65. data/lib/hecks/vocabulary.rb +2 -1
  66. metadata +23 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d66cdbdb775a92f3cfcf3f626637cffd6fb81915d7edbcccfe21e1b7c980606
4
- data.tar.gz: a43786075ea299d26d6d1f23075cf2176fdad15b7efa235234c4a6e321ec15f3
3
+ metadata.gz: b85a8382bb201f9ab54417b3535d8fe1ede57c842f627e32a2ff75d7f2dd6618
4
+ data.tar.gz: e4852fd6b22145bec8a215e4c46fc6347527f7bd05811ff7da780e74dc24fe1d
5
5
  SHA512:
6
- metadata.gz: ac3883e9e6ebeef723718dfa39785d2dcdd0e57e613f6ca21a9519040eb11278f22eedbe597965d1b28e3dfa08b98ac2d0a989686cd7b8aec0d8d97dd476acec
7
- data.tar.gz: f610074205ca1a39121169ef02109445b60ca66bf517c1a8351e8c761a1863fccfe0f9b41075fa00cc8225c017c82eefdb47c38566469fe378d0879aec115860
6
+ metadata.gz: a538ddbe90fed2ccfa8c066f00777699b0eadade3c6523e6fbf5f62c19385ab7ace8e2c8de6fabafffce46abbefd36642401f6e5f23696127445378e95d98aad
7
+ data.tar.gz: e22574943d4246998ba9121c0c84d7b39ec944416066ad881f144f166a09deb5c1a1fbb8b6c8378084b89856ed9a4e5bfd9c66a3cd6d07fa9a9a21eee77d63fc
@@ -17,8 +17,65 @@ module Hecks
17
17
  end
18
18
  end
19
19
 
20
+ # An EXPLICIT, opt-in maintenance operation — never run
21
+ # automatically after an ordinary save/delete. Heki's journal is
22
+ # not a disposable write-ahead log: it is this adapter's own
23
+ # answer to `entries`, and `entries` is a real port contract
24
+ # (`Ports::Persistence::AppendOnly`'s own required-methods list)
25
+ # read in full, forever, by `Ports::Projection::Worker#catch_up!`
26
+ # and `Registry#projection_current?` to catch a projection up to
27
+ # its authoritative source, and by `bin/history` to show "every
28
+ # journal entry a domain's append-only adapters hold" — the same
29
+ # contract Postgres/Sqlite/D1 uphold by way of a journal TABLE
30
+ # that is never pruned. A real example (`examples/banking`,
31
+ # `persisted_by("Heki")` + `projected_by("SqliteProjection")`)
32
+ # depends on this today. Compacting throws that full history away
33
+ # for whatever happened before the call — correct only for an
34
+ # aggregate nothing ever projects from; callers (`bin/
35
+ # heki_compact`) are responsible for confirming that first.
36
+ #
37
+ # Crash-safety ordering: `write` below is the exact same
38
+ # temp-file, fsync, atomic-rename sequence `save`/`delete`
39
+ # already use — the snapshot it produces is confirmed durably on
40
+ # disk before this method ever touches the journal. Only once
41
+ # that succeeds does `truncate_journal!` run. A crash between the
42
+ # two leaves old (now fully redundant) journal lines in place;
43
+ # replaying them again over the fresh snapshot on the next boot
44
+ # is idempotent — the same value gets set again, never a wrong
45
+ # one — so nothing is lost, just a little wasted replay work
46
+ # once. A crash before `write` completes leaves the journal
47
+ # fully intact and the prior snapshot untouched, exactly today's
48
+ # existing crash-recovery guarantee.
49
+ def compact!
50
+ with_lock do
51
+ current = replay_journal(read_snapshot)
52
+ write(current)
53
+ truncate_journal!
54
+ @store = current
55
+ end
56
+ end
57
+
20
58
  private
21
59
 
60
+ def truncate_journal!
61
+ return unless File.exist?(@journal_path)
62
+
63
+ # A single `truncate(0)` syscall on an already-open file
64
+ # descriptor — the file's length changes atomically at the
65
+ # filesystem level, so there is no "half truncated" state to
66
+ # observe even under a crash mid-call. `fsync` below makes
67
+ # that change durable before this method returns; without it
68
+ # a crash could still leave the old (harmless-to-replay)
69
+ # content on disk after a normal return, which is fine per
70
+ # the crash-safety note above, but the durable case is the
71
+ # one actually worth returning success for.
72
+ File.open(@journal_path, "r+b") do |file|
73
+ file.truncate(0)
74
+ file.flush
75
+ file.fsync
76
+ end
77
+ end
78
+
22
79
  def replay_journal(records)
23
80
  return records unless File.exist?(@journal_path)
24
81
 
@@ -14,4 +14,9 @@ Hecks.adapter "PostgresEra" do
14
14
  field :database
15
15
  field :role
16
16
  field :schema
17
+ # `allow_superuser true` — the explicit, on-the-record opt-in to boot
18
+ # over a connection whose role is a Postgres superuser or carries
19
+ # BYPASSRLS, which the era write-fence (row-level security) cannot
20
+ # bite. Refused by default; see `Lineage#check_fence_applies!` (BUG#24).
21
+ field :allow_superuser
17
22
  end
@@ -0,0 +1,145 @@
1
+ require "openssl"
2
+ require "json"
3
+ require "rack"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ # THE DRIVING SIDE — code an OUTSIDE caller reaches IN through,
8
+ # rather than code the domain reaches OUT through. Every existing
9
+ # file under `adapters/driven/` is the latter: a store or reader a
10
+ # `persisted_by`/`port` binding resolves TO, called BY this
11
+ # framework's own runtime. Nothing under this repository has ever
12
+ # been the mirror image before — code that receives a request FROM
13
+ # the outside world and turns it into a dispatch — so this is the
14
+ # first entry, and the directory itself is new.
15
+ module Driving
16
+ # A GITHUB WEBHOOK RECEIVER, TRANSPORT ONLY — the same split
17
+ # `Hecks::Adapters::GithubChecks` (qa/adapters/github_checks.rb,
18
+ # this class's own PULL-side sibling) already draws for itself:
19
+ # THIS file owns proving a request really came from GitHub and
20
+ # unwrapping GitHub's own webhook envelope (`X-GitHub-Event`, the
21
+ # JSON body, GitHub's own automatic `ping` check) — never which
22
+ # commands to dispatch about what it finds inside. That is exactly
23
+ # as domain-specific as `GithubChecks#run` turning `check-runs`
24
+ # JSON into green-or-raise, and lives exactly where that class's
25
+ # own header explains such logic belongs: outside this library,
26
+ # in `qa/adapters/github_ci_webhook.rb`, the subclass of this file
27
+ # that actually knows what a `QualityControl::Clearance` is.
28
+ #
29
+ # A PLAIN RACK APP (`#call(env)`) — no Sinatra, no Rails — the same
30
+ # shape `Hecks::Forms::App` (lib/hecks/forms/app.rb) already
31
+ # established for the one other HTTP-facing surface this library
32
+ # ships. `rack` is a LAZY Gemfile dependency for exactly the reason
33
+ # that file's own header gives: this file is never required by
34
+ # `require "hecks"` (nothing under `adapters.rb`'s own eager
35
+ # `adapters/driven` load names it — see that file's own header),
36
+ # so a project that never mounts a driving adapter never needs
37
+ # `rack` installed, the same "opt in by requiring the file at all"
38
+ # contract `hecks/forms.rb` already has for `Forms::App`.
39
+ #
40
+ # SUBCLASS RESPONSIBILITY: implement `#handle_event(event, action,
41
+ # payload)`, returning `[http_status, response_body_hash]`. Called
42
+ # ONLY after the signature has verified and the body has parsed as
43
+ # JSON — a subclass never has to re-check either. `event` is
44
+ # GitHub's own `X-GitHub-Event` header value ("check_suite",
45
+ # "check_run", "pull_request", ...); `action` is the payload's own
46
+ # top-level `"action"` field when it has one (GitHub's webhooks
47
+ # nearly all carry one — "completed", "requested", "opened", ...)
48
+ # and nil when it does not. `ping` — GitHub's own automatic
49
+ # connectivity check, sent once when a webhook is first saved in
50
+ # repository settings — is answered here and never reaches a
51
+ # subclass at all; there is nothing domain-specific to decide
52
+ # about it.
53
+ class GithubWebhook
54
+ # REFUSED, LOUDLY — the same shape a domain refusal already takes
55
+ # everywhere else in this codebase (`Runtime::DOMAIN_REFUSALS`,
56
+ # `Forms::App`'s own `{error:, message:}` JSON body for a bad
57
+ # command). A request that cannot prove it came from GitHub gets
58
+ # a real 401 and a named reason, never a silent 200 that would
59
+ # let a forged "CI passed" payload regress nothing while looking
60
+ # exactly like success in a log nobody re-reads.
61
+ class InvalidSignature < StandardError; end
62
+
63
+ # THE BODY DID NOT EVEN PARSE — distinct from a signature refusal:
64
+ # this body genuinely came from whoever signed it (checked
65
+ # FIRST, before parsing ever runs — see `#call`), and simply
66
+ # is not JSON. Still refused, never guessed at.
67
+ class MalformedPayload < StandardError; end
68
+
69
+ SIGNATURE_HEADER = "HTTP_X_HUB_SIGNATURE_256".freeze
70
+ EVENT_HEADER = "HTTP_X_GITHUB_EVENT".freeze
71
+
72
+ # `secret:` HAS NO DEFAULT, ON PURPOSE — the same rule
73
+ # `GoogleAuthentication`'s own header states for its own
74
+ # `ENV.fetch`, restated here because the consequence is worse for
75
+ # a webhook: an unverified signature check is not "half
76
+ # configured", it is NO verification at all, silently accepting
77
+ # anything claiming to be GitHub. A caller passes the real
78
+ # secret explicitly — from `ENV.fetch("GITHUB_WEBHOOK_SECRET")`
79
+ # or wherever it keeps one — rather than this class reaching into
80
+ # the environment itself and hiding that requirement inside a
81
+ # default.
82
+ def initialize(secret:)
83
+ raise ArgumentError, "no webhook secret configured" if secret.to_s.empty?
84
+
85
+ @secret = secret
86
+ end
87
+
88
+ def call(env)
89
+ request = Rack::Request.new(env)
90
+ return respond(405, error: "MethodNotAllowed", message: "POST only") unless request.post?
91
+
92
+ body = request.body.read
93
+ verify_signature!(request, body)
94
+
95
+ event = request.get_header(EVENT_HEADER)
96
+ return respond(200, ok: true, event: "ping") if event == "ping"
97
+ return respond(400, error: "MissingEvent", message: "no #{EVENT_HEADER} header") if event.to_s.empty?
98
+
99
+ payload = parse_json(body)
100
+ status, result = handle_event(event, payload["action"], payload)
101
+ respond(status, result)
102
+ rescue InvalidSignature => e
103
+ respond(401, error: "InvalidSignature", message: e.message)
104
+ rescue MalformedPayload => e
105
+ respond(400, error: "MalformedPayload", message: e.message)
106
+ end
107
+
108
+ private
109
+
110
+ # CONSTANT-TIME COMPARE, NOT `==`. A byte-by-byte `==` returns
111
+ # the moment it finds the first mismatching byte, so how LONG
112
+ # that took leaks how many leading bytes of a forged signature
113
+ # were already right to anyone timing the response — GitHub's
114
+ # own webhook documentation calls this out by name and recommends
115
+ # exactly the constant-time compare `Rack::Utils.secure_compare`
116
+ # already gives for free, reused rather than hand-rolled.
117
+ def verify_signature!(request, body)
118
+ header = request.get_header(SIGNATURE_HEADER)
119
+ raise InvalidSignature, "missing #{SIGNATURE_HEADER.sub('HTTP_', '').tr('_', '-')} header" if header.to_s.empty?
120
+
121
+ digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), @secret, body)
122
+ expected = "sha256=#{digest}"
123
+ return if Rack::Utils.secure_compare(expected, header)
124
+
125
+ raise InvalidSignature,
126
+ "signature does not match — refusing a payload that cannot be proven to be GitHub's own"
127
+ end
128
+
129
+ def parse_json(body)
130
+ JSON.parse(body)
131
+ rescue JSON::ParserError => e
132
+ raise MalformedPayload, e.message
133
+ end
134
+
135
+ def handle_event(event, action, payload)
136
+ raise NotImplementedError, "#{self.class} must implement #handle_event(event, action, payload)"
137
+ end
138
+
139
+ def respond(status, body)
140
+ [status, { "content-type" => "application/json" }, [JSON.generate(body)]]
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -194,11 +194,24 @@ module Hecks
194
194
  # mixed facts into the strict envelope — identities lifted into
195
195
  # `to:`, declared facts into `with:` — so a behaviors dispatch now
196
196
  # goes through the exact same separation a policy's projection
197
- # does. A verb that resolves to no command (a port operation
198
- # "Pizzas::Order.PaymentGateway.Receive") keeps the loose
199
- # passthrough: its own input already spells the port form's
200
- # `to:`/`with:`, which the dispatcher's port branch reads directly.
197
+ # does. A verb that names a port operation (checked explicitly,
198
+ # below — "Pizzas::Order.PaymentGateway.Receive") keeps the loose
199
+ # passthrough instead: its own input already spells the port
200
+ # form's `to:`/`with:`, which the dispatcher's port branch reads
201
+ # directly, and `ReactionInvocation.build`'s explicit envelope
202
+ # expects a command's own declared attributes at the top level,
203
+ # not a port operation's already-wrapped `to:`/`with:` shape.
204
+ #
205
+ # THIS USED TO RELY ON `resolve_target` RAISING `UnknownVerb` for
206
+ # any port-operation verb — true only so long as nothing else ever
207
+ # asked it to resolve one. Now that a `policy` can legitimately
208
+ # `trigger` a port operation (`ReactionInvocation#resolve_target`'s
209
+ # own port-operation branch), that raise is gone, so this checks
210
+ # for a port operation directly instead of leaning on a refusal
211
+ # that no longer happens.
201
212
  def dispatch_command(runtime, verb, args)
213
+ return runtime.dispatch(verb, **args) if port_operation?(runtime, verb)
214
+
202
215
  invocation = begin
203
216
  Runtime::ReactionInvocation.build(registry: runtime.registry, verb: verb,
204
217
  projected: args, explicit: true)
@@ -214,6 +227,21 @@ module Hecks
214
227
  end
215
228
  end
216
229
 
230
+ # THE SAME "Head.Rest" SHAPE `Dispatcher#dispatch` AND
231
+ # `ReactionInvocation#resolve_target` BOTH ALREADY CHECK — a bare
232
+ # domain/aggregate lookup plus a port-name lookup, no command
233
+ # resolution needed since all this asks is whether one exists.
234
+ def port_operation?(runtime, verb)
235
+ domain, aggregate_name, command_path = Naming.split_verb(verb)
236
+ return false unless command_path
237
+
238
+ aggregate = runtime.registry.bluebook(domain)&.aggregate(aggregate_name)
239
+ return false unless aggregate
240
+
241
+ head, rest = command_path.split(".", 2)
242
+ rest && !!aggregate.port(head)
243
+ end
244
+
217
245
  def run_query(test, runtime, verb)
218
246
  rows = runtime.query(verb, **test.input)
219
247
 
@@ -11,9 +11,33 @@ module Hecks
11
11
  # what it acts for, and `identity_attribute` is how that is found.
12
12
  def references = nil
13
13
 
14
+ # NEVER a creating command — a port operation always acts on an
15
+ # aggregate that already exists (`operation.to`/`identity_attribute`
16
+ # both name where its RECEIVER comes from, never a birth). Answered
17
+ # explicitly, not derived from `references` the way `Command
18
+ # #creates?` is (`references.nil?` would read every operation as
19
+ # creating, since `references` above is unconditionally nil) —
20
+ # needed so `ReactionInvocation#source_receiver_for` can call
21
+ # `target.command.creates?` on a port operation the same way it
22
+ # already does on an ordinary command, and correctly lift a
23
+ # same-aggregate policy's own Event.id as the operation's receiver.
24
+ def creates? = false
25
+
14
26
  def identity_attribute(owner_name)
15
27
  @attributes.find { |attribute| attribute.reference? && attribute.type.target_name == owner_name.to_s }
16
28
  end
29
+
30
+ # THE SAME READING `Command#addressing_key_for` gives, minus its
31
+ # self-addressing branch — a port operation's `references` is
32
+ # unconditionally nil (above), so it never means "this verb is
33
+ # declared ON the very aggregate it acts on" the way a command's
34
+ # does; a port operation's only path back to its owner is a real,
35
+ # declared reference-typed attribute, which is exactly what
36
+ # `identity_attribute` already finds. Needed for the identical
37
+ # reason `Command#addressing_key_for` is: `ReactionInvocation
38
+ # #aggregate_aliases` calls it on whatever `target.command` holds,
39
+ # a `PortOperation` now included since a policy can trigger one.
40
+ def addressing_key_for(aggregate_name) = identity_attribute(aggregate_name)&.name
17
41
  end
18
42
 
19
43
  # WHAT A PORT DOES — one finder over its declared operations.
@@ -117,6 +117,28 @@ module Hecks
117
117
  offer(label) { @runtime.dispatch(verb, to: to, with: args(payload)) }
118
118
  end
119
119
 
120
+ # THE RECEIVER, SPELLED THE WAY A REAL CALLER ADDRESSES IT.
121
+ #
122
+ # `receiver` is an internal accumulator — it tracks the aggregate this
123
+ # walk is inside AND, once it crosses into a real entity-owned category
124
+ # (Member/Handler/Dispatch — S17, ADR 0026), every entity hop on top of
125
+ # it. But `Routing.envelope`/`parse_envelope_hash` do not want that
126
+ # shape restated when there is no entity to route to: a plain aggregate
127
+ # command is addressed by its bare id (`Facade::Handle#dispatch` — "to:
128
+ # @id" — and `CommandRequest`'s own header: "aggregate command: { to:
129
+ # "record-id", ... }"), never `{aggregate:, entities: []}` — that hash
130
+ # is the ENTITY route's own shape, degenerate with nothing in it.
131
+ #
132
+ # So THIS is the one place `receiver` turns into a `to:` value: bare
133
+ # when there is no entity hop (the overwhelming common case — every
134
+ # category but those three), the full envelope only when there
135
+ # genuinely is one.
136
+ def address(receiver)
137
+ return receiver[:aggregate] if receiver[:entities].empty?
138
+
139
+ receiver
140
+ end
141
+
120
142
  def judge!
121
143
  declare_node("Bluebook", @bluebook, nil, 0)
122
144
  detail_node("Bluebook", @bluebook, nil, 0)
@@ -384,7 +406,7 @@ module Hecks
384
406
  end
385
407
  next if payload.values.all?(&:nil?)
386
408
 
387
- send_to("Bluebook::#{verb_for(plan, setter.verb)}", receiver[:aggregate], to: receiver, **payload)
409
+ send_to("Bluebook::#{verb_for(plan, setter.verb)}", receiver[:aggregate], to: address(receiver), **payload)
388
410
  end
389
411
  end
390
412
 
@@ -409,7 +431,7 @@ module Hecks
409
431
  end
410
432
 
411
433
  send_to("Bluebook::#{verb_for(plan, chosen.verb)}", "#{id}##{list_name}[#{index}]",
412
- to: receiver, **payload)
434
+ to: address(receiver), **payload)
413
435
  end
414
436
  end
415
437
  end
@@ -430,7 +452,7 @@ module Hecks
430
452
 
431
453
  def sealers(plan, _category, receiver)
432
454
  id = receiver[:entities].last || receiver[:aggregate]
433
- plan.sealers.each { |verb| send_to("Bluebook::#{verb_for(plan, verb)}", id, to: receiver) }
455
+ plan.sealers.each { |verb| send_to("Bluebook::#{verb_for(plan, verb)}", id, to: address(receiver)) }
434
456
  end
435
457
 
436
458
  # An aggregate's or an entity's attribute names its value object by TYPE,
@@ -65,6 +65,40 @@ module Hecks
65
65
  [:unacknowledged_relationship, "FlagKeyReturn"],
66
66
  [:unknown_target_domain, "FlagKeyReturn"]
67
67
  ]
68
+ # QualityControl WAS the first domain in this corpus to trigger an
69
+ # `asks`/`tells` PORT OPERATION from a `policy`, and used to carry
70
+ # two entries here for it — both GONE now, not just quieted:
71
+ #
72
+ # `deaf_policy` (ClearOnPass, RefuseOnFail, RecordTheIssue,
73
+ # RecordTheRefusal) went first: `emitted_events` below now reads an
74
+ # outbound operation's `.answers`/`.refuses` the same way it already
75
+ # read a command's `.emits`, so `Clearance.SuitePassed`/`SuiteFailed`
76
+ # and `Ticket.IssueFiled`/`IssueFilingRefused` enter the known-emits
77
+ # set for real — the same fix `bin/qa_pr_check`'s own move to
78
+ # dispatching through the CI port (rather than `Clearance::Passed`/
79
+ # `Failed` directly) needed to make these two policies actually fire.
80
+ #
81
+ # `unknown_trigger` (FileWhenSubmitted, AskOnceMore) — BUG#23 — was
82
+ # never actually a `Naming`/`PolicyBuilder` defect, confirmed by
83
+ # tracing the real dispatch path rather than assuming the comment
84
+ # that used to sit here: `Naming.command_ref`'s bare-constant
85
+ # rewrite DOES leave `trigger Ticket::IssueTracker::File` (aggregate,
86
+ # port, operation) as "Ticket::IssueTracker.File", a leftover `::`
87
+ # past the aggregate — but `PolicyInterpreter#deliver` re-qualifies
88
+ # every trigger with this domain's own name before dispatch
89
+ # ("QualityControl::Ticket::IssueTracker.File"), and `Naming.
90
+ # split_verb` already folds that reintroduced `::` into the
91
+ # dot-joined tail correctly (fixed for `ReactionInvocation#
92
+ # resolve_target`, PR #520, predating this entry's own removal) —
93
+ # confirmed live: a real dispatch through `Ticket.Submit` fires
94
+ # `IssueFiled`/`TicketFiled` exactly as declared. The actual gap was
95
+ # entirely in THIS checker: `verbs_of` never enumerated a port
96
+ # operation as a triggerable verb at all, and `policy_findings`
97
+ # compared raw strings instead of `Naming.split_verb` triples the
98
+ # way `handler_findings`'s own `unknown_dispatch` check already does
99
+ # (BUG#6). Fixed with `port_verbs_of`/`triggerable_verbs`, scoped
100
+ # entirely to this file — no change to `Naming` or `PolicyBuilder`
101
+ # was needed or made.
68
102
  }.freeze
69
103
 
70
104
  module_function
@@ -215,7 +249,12 @@ module Hecks
215
249
 
216
250
  def saga_findings(bluebook, process_manager)
217
251
  emitted = emitted_events(bluebook)
218
- verbs = verbs_of(bluebook)
252
+ # (domain, aggregate, command) TRIPLES, not raw strings — see
253
+ # `handler_findings`'s own comment on the dispatch side for why:
254
+ # `Naming.split_verb` is what makes an entity verb's two legitimate
255
+ # spellings (`Naming.command_ref`'s own `::`-then-`.` rewrite vs.
256
+ # `verbs_of`'s own all-`.` one) compare equal.
257
+ verbs = verbs_of(bluebook).map { |verb| Naming.split_verb(verb) }
219
258
  reached = pm_reachable_states(process_manager, emitted)
220
259
 
221
260
  findings = []
@@ -266,16 +305,38 @@ module Hecks
266
305
  end
267
306
 
268
307
  handler.dispatches.each do |dispatch|
269
- # SAME-DOMAIN, same as `SagaInterpreter#qualified` — a dispatch
270
- # naming no domain at all (the ordinary shape a bare command
271
- # constant now produces, S6) means THIS one, and is compared
272
- # against `verbs_of`'s own fully-qualified spelling qualified
273
- # the identical way, not left bare to miss it on a technicality.
274
- qualified = if dispatch.command_name.include?("::")
275
- dispatch.command_name
276
- else
277
- "#{bluebook.name}::#{dispatch.command_name}"
278
- end
308
+ # ALWAYS THIS DOMAIN — same fix, same reason, as `SagaInterpreter
309
+ # #qualified` (BUG#6). This used to guess: a dispatch whose own
310
+ # `command_name` still carried a leftover `::` after `Naming.
311
+ # command_ref`'s own rewrite was read as "already qualified" and
312
+ # left alone the exact same string-shape ambiguity that
313
+ # `SagaInterpreter#qualified`'s own comment explains at length
314
+ # (a same-domain entity command reference and a genuinely
315
+ # cross-domain one are textually indistinguishable after that
316
+ # rewrite). Confirmed against the entire corpus, same as that
317
+ # fix: no saga anywhere ever dispatches genuinely cross-domain,
318
+ # so this checker now qualifies exactly the way the runtime
319
+ # actually dispatches — unconditionally against `bluebook.name`
320
+ # — instead of maintaining its own, independently-wrong copy of
321
+ # the same guess.
322
+ #
323
+ # COMPARED AS A TRIPLE, NOT A STRING — `Naming.command_ref`'s
324
+ # own rewrite of an entity reference (`Manifest::Slot::Fill`)
325
+ # collapses to "Manifest::Slot.Fill" (`::` between aggregate and
326
+ # entity, `.` before the command); `verbs_of`'s own entity
327
+ # spelling, below, joins aggregate/entity/command all with `.`
328
+ # instead (matching `fuzzing/sequence_generator/catalog.rb`'s own
329
+ # independent convention, its comment's own "the same spelling"
330
+ # claim). Both are legitimate, and `Naming.split_verb` already
331
+ # parses either to the identical (domain, aggregate, command)
332
+ # triple (its own comment: "past the already-resolved domain
333
+ # boundary, any leftover `::` is unambiguous... folding it into
334
+ # the dot-joined tail") — the same reading `ReactionInvocation.
335
+ # resolve_target` relies on at runtime. A bare string `include?`
336
+ # would falsely flag every entity dispatch as unknown_dispatch
337
+ # even once correctly domain-qualified, comparing two spellings
338
+ # of the same verb as though they were different ones.
339
+ qualified = Naming.split_verb("#{bluebook.name}::#{dispatch.command_name}")
279
340
  next if verbs.include?(qualified)
280
341
 
281
342
  findings << Finding.new(kind: :unknown_dispatch, severity: :error, subject: process_manager.name,
@@ -366,9 +427,27 @@ module Hecks
366
427
  # `trigger` is spelled "Aggregate.Command" (or "Entity.Command" one
367
428
  # level down), completed to an FQN by PolicyInterpreter#deliver as
368
429
  # "#{domain}::#{trigger_command}" — the same join `verbs_of` builds
369
- # independently, so the two spellings have to be compared as FQNs,
370
- # never as bare command names.
371
- unless verbs_of(bluebook).include?("#{bluebook.name}::#{policy.trigger_command}")
430
+ # independently, so the two spellings have to be compared as FQNs.
431
+ #
432
+ # COMPARED AS A TRIPLE, NOT A STRING — `handler_findings`'s own
433
+ # `unknown_dispatch` check (BUG#6) already applies this fix for a
434
+ # saga's dispatch; a policy's `trigger` needed the identical one. A
435
+ # policy triggering an `asks`/`tells` PORT OPERATION (`Aggregate::
436
+ # Port::Operation`, three colon-joined segments — `Naming.command_ref`'s
437
+ # bare-constant rewrite turns this into "Aggregate::Port.Operation",
438
+ # a leftover `::` past the aggregate) is a real, WORKING dispatch —
439
+ # `PolicyInterpreter#deliver` qualifies it with this domain's own
440
+ # name before `Naming.split_verb` ever sees it, and `split_verb`
441
+ # already folds that leftover `::` into the dot-joined tail
442
+ # correctly (fixed for `ReactionInvocation#resolve_target`, PR
443
+ # #520) — but this check compared raw strings against `verbs_of`,
444
+ # which never enumerated port operations at all, so it reported
445
+ # every port-operation trigger as unknown regardless. `triggerable_
446
+ # verbs` now includes both, and both sides are parsed through
447
+ # `Naming.split_verb` before comparing, the same reading
448
+ # `resolve_target` relies on at runtime.
449
+ qualified = Naming.split_verb("#{bluebook.name}::#{policy.trigger_command}")
450
+ unless qualified && triggerable_verbs(bluebook).include?(qualified)
372
451
  findings << Finding.new(kind: :unknown_trigger, severity: :error, subject: policy.name,
373
452
  message: "trigger #{policy.trigger_command.inspect} resolves to no command " \
374
453
  "this domain declares")
@@ -464,15 +543,38 @@ module Hecks
464
543
  # the fixtures under spec/fixtures/model_check/ do, having no
465
544
  # hecksagon at all) simply finds none, which is correct : nothing
466
545
  # can be deaf to an event that isn't even wired up yet.
546
+ #
547
+ # AN OUTBOUND OPERATION (`asks`) EMITS THROUGH A DIFFERENT DOOR — it
548
+ # declares no `.emits` at all (`PortOperationBuilder#refuse_wrong_
549
+ # words!` refuses one that tries), naming its two real endings
550
+ # `.answers`/`.refuses` instead (`PortOperation#initialize`). Reading
551
+ # only `.emits` left every `asks`'s own two events invisible to this
552
+ # method — real, live events a policy genuinely reacts to
553
+ # (`Clearance.SuitePassed`/`SuiteFailed`, `Ticket.IssueFiled`/
554
+ # `IssueFilingRefused`), reported as `deaf_policy` findings until this
555
+ # read both. `.compact` because an INBOUND operation's `.answers`/
556
+ # `.refuses` are always nil (there is no channel back to tell), which
557
+ # would otherwise seed every emitted-events set with a stray nil.
467
558
  def emitted_events(bluebook)
468
559
  aggregate_emits = bluebook.aggregates.flat_map do |aggregate|
469
560
  aggregate.commands.map(&:emits) +
470
561
  aggregate.entities.flat_map { |entity| entity.commands.map(&:emits) } +
471
- aggregate.ports.flat_map { |port| port.operations.map(&:emits) }
562
+ port_operation_events(aggregate.ports)
472
563
  end
473
- chapter_emits = bluebook.ports.flat_map { |port| port.operations.map(&:emits) }
564
+ chapter_emits = port_operation_events(bluebook.ports)
565
+
566
+ (aggregate_emits + chapter_emits).flatten.compact.uniq
567
+ end
474
568
 
475
- (aggregate_emits + chapter_emits).flatten.uniq
569
+ # ONE OPERATION, EITHER OF ITS OWN SOURCES OF EVENTS — an inbound
570
+ # `tells` names its own via `.emits`; an outbound `asks` has none
571
+ # (`PortOperationBuilder#refuse_wrong_words!` refuses one that
572
+ # tries) and names its two real endings `.answers`/`.refuses`
573
+ # instead. Pulled out of `emitted_events` above purely to keep that
574
+ # method's own branching low enough to read at a glance — every
575
+ # port, aggregate-owned or chapter-level, asks this the same way.
576
+ def port_operation_events(ports)
577
+ ports.flat_map { |port| port.operations.flat_map { |op| [*op.emits, op.answers, op.refuses] } }
476
578
  end
477
579
 
478
580
  # Fully-qualified, the same spelling DispatchSpec#command_name
@@ -490,6 +592,35 @@ module Hecks
490
592
  end
491
593
  end
492
594
 
595
+ # AN AGGREGATE-OWNED PORT OPERATION IS A TRIGGERABLE VERB TOO —
596
+ # `ReactionInvocation#resolve_target`'s own port-operation branch
597
+ # resolves one by the exact same two-segment tail shape ("Aggregate::
598
+ # Port.Operation", the aggregate then the port then the operation,
599
+ # dot-joined past the domain) an entity command uses, checked first,
600
+ # same order `Dispatcher#dispatch` already resolves a live verb in.
601
+ # Only an aggregate's OWN ports (`aggregate.ports`) are in scope here
602
+ # — a policy's `trigger` always names one aggregate, never a chapter-
603
+ # level port with no owner to address through.
604
+ def port_verbs_of(bluebook)
605
+ bluebook.aggregates.flat_map do |aggregate|
606
+ aggregate.ports.flat_map do |port|
607
+ port.operations.map do |operation|
608
+ "#{bluebook.name}::#{aggregate.hecks_name}.#{port.name}.#{operation.hecks_name}"
609
+ end
610
+ end
611
+ end
612
+ end
613
+
614
+ # EVERY TRIGGERABLE VERB, AS A TRIPLE — `verbs_of` (ordinary/entity
615
+ # commands) plus `port_verbs_of` (port operations), each parsed
616
+ # through `Naming.split_verb` so a caller never has to compare two
617
+ # spellings of the same verb as strings (see `policy_findings`'s own
618
+ # `unknown_trigger` check for why that comparison has to happen this
619
+ # way, not as `include?` on a raw string).
620
+ def triggerable_verbs(bluebook)
621
+ (verbs_of(bluebook) + port_verbs_of(bluebook)).to_set { |verb| Naming.split_verb(verb) }
622
+ end
623
+
493
624
  def bare(event) = event.to_s.split("::").last
494
625
  end
495
626
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "../bluebook/attribute"
2
+ require_relative "../naming"
2
3
  require_relative "value_object_shape"
3
4
 
4
5
  module Hecks
@@ -36,10 +37,11 @@ module Hecks
36
37
  # genuinely dotted path ("amount.cents") drops everything before
37
38
  # the last ".".
38
39
  segment = text.to_s.split(".").last.to_s
39
- words = segment.split("_")
40
- return segment if words.empty?
40
+ return segment if segment.empty?
41
41
 
42
- ([words.first.capitalize] + words.drop(1)).join(" ")
42
+ # The word split itself is `Naming.words`' now — the one
43
+ # humanizer, shared with the glossary projection.
44
+ Naming.words(segment)
43
45
  end
44
46
 
45
47
  # The full dotted path, each segment humanized and joined with