hecks 1.1.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 (85) 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/outbox.rb +7 -4
  4. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  5. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
  6. data/lib/hecks/adapters/driven/postgres.rb +20 -17
  7. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
  9. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  10. data/lib/hecks/behaviors/expectations.rb +32 -4
  11. data/lib/hecks/bluebook/assembly/marks.rb +13 -3
  12. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  13. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  14. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
  15. data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
  16. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  17. data/lib/hecks/bluebook/model_check.rb +148 -17
  18. data/lib/hecks/forms/field_shape.rb +5 -3
  19. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  20. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  21. data/lib/hecks/fuzzing/form_census.rb +184 -0
  22. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  23. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  24. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  25. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  26. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  27. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  28. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  29. data/lib/hecks/fuzzing/properties.rb +23 -2
  30. data/lib/hecks/fuzzing/replay.rb +130 -20
  31. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  32. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  33. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  34. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  35. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  36. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  37. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  38. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  39. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  40. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  41. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  42. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  43. data/lib/hecks/fuzzing.rb +6 -0
  44. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  45. data/lib/hecks/naming.rb +70 -2
  46. data/lib/hecks/ports/persistence/append_only.rb +11 -0
  47. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  48. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  49. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +85 -5
  50. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  51. data/lib/hecks/projections/glossary/html.rb +250 -0
  52. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  53. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  54. data/lib/hecks/projections/glossary/page.css +271 -0
  55. data/lib/hecks/projections/glossary/page.js +72 -0
  56. data/lib/hecks/projections/glossary/sections.rb +17 -0
  57. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  58. data/lib/hecks/projections/glossary.rb +302 -0
  59. data/lib/hecks/projections.rb +1 -0
  60. data/lib/hecks/projector/cli_projector.rb +29 -13
  61. data/lib/hecks/projector/exporter.rb +24 -0
  62. data/lib/hecks/projector/narrate_projector.rb +4 -11
  63. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  64. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  65. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  66. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  67. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  68. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  69. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  70. data/lib/hecks/runtime/entity_element.rb +168 -16
  71. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  72. data/lib/hecks/runtime/interpreting.rb +16 -7
  73. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  74. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  75. data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
  76. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  77. data/lib/hecks/runtime/registry.rb +12 -0
  78. data/lib/hecks/runtime/routing.rb +67 -2
  79. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  80. data/lib/hecks/runtime/value/coercion.rb +77 -52
  81. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  82. data/lib/hecks/runtime/value.rb +7 -2
  83. data/lib/hecks/version.rb +1 -1
  84. data/lib/hecks/vocabulary.rb +2 -1
  85. metadata +25 -2
@@ -14,14 +14,59 @@ module Hecks
14
14
 
15
15
  def query_name = Naming.snake(@name)
16
16
 
17
- # WHICH GATHERED HEAD THE FILTERING APPLIES TO, so the read-model
18
- # interpreter can ask this directly rather than re-deriving or
19
- # re-checking it.
20
- def filtered_head_name
21
- return nil unless wheres.any? || order_by || limit || offset || authorization&.tenant ||
22
- @group_by.any? || count? || @median_field
23
-
24
- @aggregate_heads.find { |head| head[:many] }&.fetch(:as)
17
+ # WHICH GATHERED HEADS THE FILTERING APPLIES TO (ADR 0055) — plural,
18
+ # since `where`/`order_by`/`limit`/`offset` can now each independently
19
+ # name a many-side head via `on:` once there's more than one. A read
20
+ # model with a single many-side head keeps the old reading: every
21
+ # UNTARGETED option (plus `group_by`/`count`/`median`, still
22
+ # single-head-only ADR 0055) applies to it, same as before `on:`
23
+ # existed. With several many-side heads, only the ones actually named
24
+ # by a targeted option are eligible.
25
+ def filtered_head_names
26
+ many = @aggregate_heads.select { |head| head[:many] }
27
+ return [] if many.empty?
28
+
29
+ return single_filtered_head_name(many) if many.one?
30
+
31
+ targets = (wheres.map(&:target) + [order_by&.target, limit&.target, offset&.target]).compact.uniq
32
+ targets.filter_map { |target| many.find { |head| head[:aggregate] == target.to_s } }.map { |head| head[:as] }
33
+ end
34
+
35
+ # The pre-`on:` reading (ADR 0055), unchanged: with exactly one
36
+ # many-side head, every UNTARGETED option (plus `group_by`/`count`/
37
+ # `median`, still single-head-only) applies to it — split out only
38
+ # to keep `filtered_head_names` itself under this file's own
39
+ # complexity budget, not because the two questions differ in kind.
40
+ def single_filtered_head_name(many)
41
+ declared = wheres.any? || order_by || limit || offset || authorization&.tenant ||
42
+ @group_by.any? || count? || @median_field
43
+ declared ? [many.first[:as]] : []
44
+ end
45
+
46
+ # THE where/order_by/limit/offset THAT APPLY TO ONE ELIGIBLE HEAD
47
+ # (ADR 0055) — a small view `Ports::Query::InMemory.execute` reads
48
+ # exactly the way it already reads a whole `Query`/`ReadModel`
49
+ # (`.wheres`/`.order_by`/`.limit`/`.offset`/`.null_semantics`), scoped
50
+ # to `head_as`'s own aggregate: an UNTARGETED option applies when
51
+ # `head_as` is the read model's ONE many-side head (the pre-`on:`
52
+ # reading, unchanged) ; a TARGETED one applies when its `target`
53
+ # resolves to `head_as`'s own aggregate.
54
+ FilteredOptions = Struct.new(:wheres, :order_by, :limit, :offset, :null_semantics)
55
+
56
+ def options_for(head_as)
57
+ many = @aggregate_heads.select { |head| head[:many] }
58
+ aggregate_name = @aggregate_heads.find { |head| head[:as] == head_as }&.fetch(:aggregate)
59
+ applies = lambda do |target|
60
+ target.nil? ? many.one? : target.to_s == aggregate_name
61
+ end
62
+
63
+ FilteredOptions.new(
64
+ wheres.select { |where| applies.call(where.target) },
65
+ order_by && applies.call(order_by.target) ? order_by : nil,
66
+ limit && applies.call(limit.target) ? limit : nil,
67
+ offset && applies.call(offset.target) ? offset : nil,
68
+ null_semantics
69
+ )
25
70
  end
26
71
  end
27
72
  end
@@ -88,13 +88,18 @@ module Hecks
88
88
  end
89
89
 
90
90
  # `optional:` — matching `CommandBuilder#reference_to`'s own
91
- # signature, which already had it; this one never forwarded it
92
- # to `attribute_impl()` even though `attribute_impl()` itself
93
- # already accepts it. A real gap: an aggregate that can point at
94
- # ONE OF several targets (Item's own `personal_list_id`/
91
+ # signature, which already had it; this one used to never
92
+ # forward it to `attribute_impl()`/`relationship_attribute`
93
+ # even though those already accept it closed in the same
94
+ # commit that added this comment (`optional: optional`, below).
95
+ # It was a real gap because an aggregate that can point at ONE
96
+ # OF several targets (Item's own `personal_list_id`/
95
97
  # `camping_list_id`, never both) needs each reference optional
96
98
  # on the aggregate's own persisted schema, not just as a
97
- # command's input.
99
+ # command's input — real corpus use:
100
+ # `spec/fixtures/hop_chain.bluebook`'s own `Proposal` aggregate
101
+ # declares `reference_to Engagement, optional: true` at the
102
+ # aggregate head.
98
103
  # RENAMED FROM `reference_to` — item #13's full metaprogrammed
99
104
  # dispatch (slice 4b). Bootstrap-reachable (every core/attached
100
105
  # grammar chapter uses reference_to to describe itself), so also
@@ -54,6 +54,72 @@ module Hecks
54
54
  @includes << [Naming.demodulise(type), as]
55
55
  end
56
56
 
57
+ # `on:` (ADR 0055) — OVERRIDES of `QuerySpecification::Common::DSL`'s
58
+ # shared `where_impl`/`order_by_impl`/`limit_impl`/`offset_impl`,
59
+ # scoped to `ReadModelBuilder` alone rather than added to the shared
60
+ # module `Query` also mixes in: a plain `query` has no
61
+ # `aggregate_heads` at all, so `on:` there would be a silently-
62
+ # ignored no-op rather than a real answer. Overriding only here
63
+ # means a `Query`'s own `where(..., on: X)` gets Ruby's own loud
64
+ # `unknown keyword: :on` instead of quietly doing nothing.
65
+ #
66
+ # `on:` names the target by TYPE (`on: Character`), resolved the
67
+ # same way `reference_to`/`include` already resolve their own type
68
+ # argument (`Naming.demodulise`) — not by the include's own `as:`
69
+ # alias. A read model that `include`s the SAME type twice under two
70
+ # different `as:` has no way to say which one `on:` means today; no
71
+ # real corpus read model does this, so it's a real, deliberate scope
72
+ # limit (see ADR 0055), not an oversight.
73
+ #
74
+ # `*positional, on:, **rest` rather than a plain `(clauses, on: nil)`
75
+ # — found necessary, not stylistic, by reproducing the failure
76
+ # directly: `where(status: "disputed")` reaches here with its
77
+ # `status: "disputed"` captured as `**kwargs` (GenericDispatch's own
78
+ # `builder.send(calls, *args, **kwargs, &block)`), and Ruby stops
79
+ # auto-converting a bare `**hash` call into a plain positional Hash
80
+ # THE MOMENT a method declares any real keyword parameter — so a
81
+ # `(clauses, on: nil)` signature raised "wrong number of arguments
82
+ # (given 0, expected 1)" on every ordinary `where(field: value)`
83
+ # call, never reaching `on:` at all. `**rest` sidesteps this: Ruby
84
+ # still auto-splits `on:` into the declared keyword and gathers
85
+ # every OTHER key into `rest` regardless of how the caller wrote it.
86
+ #
87
+ # `QuerySpecification::Common::WhereClause` etc — FULLY QUALIFIED,
88
+ # not the bare names `dsl.rb`'s own shared `where_impl` gets away
89
+ # with. That file is lexically nested inside `Common` itself, so
90
+ # `WhereClause` resolves directly; this class is nested inside
91
+ # `Bluebook::DSL`, which has no lexical or ancestor path to
92
+ # `QuerySpecification::Common` at all — a bare `WhereClause` here
93
+ # falls through to `const_missing` and, mid-bluebook-load, that's
94
+ # `ConstShim`, which resolves it against the self-hosted grammar
95
+ # domain's OWN unrelated `WhereClause` construct instead (a `Module`,
96
+ # not this `Struct`) — found directly by reproducing "undefined
97
+ # method `new' for module WhereClause" against a real corpus load,
98
+ # not guessed.
99
+ def where_impl(*positional, on: nil, **rest)
100
+ raise ArgumentError, "wrong number of arguments (given #{positional.size}, expected 1)" if positional.size > 1
101
+
102
+ @wheres ||= []
103
+ target = resolve_target(on)
104
+ clauses = (positional.first || {}).merge(rest)
105
+ clauses.each do |field, value|
106
+ op, operand = split_comparator(value)
107
+ @wheres << QuerySpecification::Common::WhereClause.new(field: field, op: op, value: operand, target: target)
108
+ end
109
+ end
110
+
111
+ def order_by_impl(field, direction = :asc, on: nil)
112
+ @order_by = QuerySpecification::Common::OrderBy.new(field: field, direction: direction, target: resolve_target(on))
113
+ end
114
+
115
+ def limit_impl(value, on: nil)
116
+ @limit = QuerySpecification::Common::LimitSpec.new(value: value, target: resolve_target(on))
117
+ end
118
+
119
+ def offset_impl(value, on: nil)
120
+ @offset = QuerySpecification::Common::OffsetSpec.new(value: value, target: resolve_target(on))
121
+ end
122
+
57
123
  # NAMES which of the eligible head's own fields to nest its rows
58
124
  # under — one level per field, the leaf being that row with the
59
125
  # named fields removed (they're already spent, as the keys that
@@ -142,27 +208,70 @@ module Hecks
142
208
 
143
209
  private
144
210
 
145
- # where/order_by/limit/offset/authorize's tenant all apply to exactly
146
- # one collection — the single `include`d aggregate whose head is
147
- # "many" (the "one" side, the reference target itself, is a single
148
- # row; ordering, paging, or tenant-scoping one row means nothing). A
149
- # read model with zero many-heads has nothing for them to filter ;
150
- # one with several has no way to say WHICH of several unrelated
151
- # collections a caller meant — so both refuse here rather than
152
- # silently applying to an arbitrary one. `authorize`'s tenant counts
153
- # here too TenantScope enforces it against this same head, so an
154
- # ambiguous target is exactly as unusable as it is for the others.
211
+ # where/order_by/limit/offset/authorize's tenant all apply to
212
+ # collections — the `include`d aggregates whose heads are "many"
213
+ # (the "one" side, the reference target itself, is a single row;
214
+ # ordering, paging, or tenant-scoping one row means nothing). ADR
215
+ # 0055 gave `where`/`order_by`/`limit`/`offset` an `on:` to name
216
+ # WHICH many-side collection they mean, so this asks two questions
217
+ # now instead of one:
218
+ #
219
+ # 1. Does every declared `on:` actually name a many-side included
220
+ # aggregate? Checked regardless of how many many-side heads
221
+ # exist — a typo refuses immediately, not only once ambiguity
222
+ # would otherwise bite.
223
+ # 2. Is there still an UNTARGETED option declared (including
224
+ # `authorize`'s own `tenant:`, which has no `on:` of its own —
225
+ # a real, deliberate scope limit, see ADR 0055)? An untargeted
226
+ # option still needs exactly one many-side head to mean
227
+ # anything unambiguous — the ORIGINAL rule, unchanged, and
228
+ # still worded the same way (`spec/runtime/
229
+ # read_model_interpreter_spec.rb`'s existing refusal regex
230
+ # still matches).
231
+ #
232
+ # A read model with several many-side heads is legal precisely when
233
+ # every declared option names one; a read model with a single
234
+ # many-side head is unaffected either way, `on:` or not.
155
235
  def seal_query_options
156
- declared = @wheres&.any? || @order_by || @limit || @offset || @authorization&.tenant
157
- return unless declared
236
+ many = Array(@aggregate_heads).select { |head| head[:many] }
158
237
 
159
- many = Array(@aggregate_heads).count { |head| head[:many] }
160
- return if many == 1
238
+ validate_declared_targets!(many)
239
+ return unless untargeted_option_declared?
240
+ return if many.size == 1
161
241
 
162
242
  raise Malformed,
163
- "#{@name} declares where/order_by/limit/offset but includes #{many} many-side " \
243
+ "#{@name} declares where/order_by/limit/offset but includes #{many.size} many-side " \
164
244
  "aggregates, not exactly one — these options apply to a single collection; " \
165
- "name which one by including only it, or drop the options"
245
+ "name which one with `on:` (e.g. `where(field: value, on: Character)`), or drop the options"
246
+ end
247
+
248
+ # Question 1 of `seal_query_options`'s own two, split out to keep
249
+ # both under the same "one job per method" shape every OTHER seal in
250
+ # this file already holds to (each raises its own one Malformed, for
251
+ # its own one reason).
252
+ def validate_declared_targets!(many)
253
+ many_by_aggregate = many.to_h { |head| [head[:aggregate], head] }
254
+ declared_targets = Array(@wheres).map(&:target) + [@order_by&.target, @limit&.target, @offset&.target]
255
+
256
+ declared_targets.compact.uniq.each do |target|
257
+ next if many_by_aggregate.key?(target)
258
+
259
+ raise Malformed,
260
+ "#{@name}'s `on: #{target}` doesn't name one of its own many-side included " \
261
+ "aggregates (it includes #{many.map { |head| head[:aggregate] }.join(', ')} as " \
262
+ "many-side heads)"
263
+ end
264
+ end
265
+
266
+ # Question 2 of `seal_query_options`'s own two — see that method's
267
+ # header. `authorize`'s own `tenant:` has no `on:` at all (ADR 0055's
268
+ # own documented scope limit), so it always counts as untargeted.
269
+ def untargeted_option_declared?
270
+ Array(@wheres).any? { |where| where.target.nil? } ||
271
+ (@order_by && @order_by.target.nil?) ||
272
+ (@limit && @limit.target.nil?) ||
273
+ (@offset && @offset.target.nil?) ||
274
+ @authorization&.tenant
166
275
  end
167
276
 
168
277
  # Same shape as `seal_query_options`, same reason — `group_by`
@@ -227,6 +336,12 @@ module Hecks
227
336
  "pagination — use limit/offset instead"
228
337
  end
229
338
 
339
+ # `on:`'s own resolution (ADR 0055) — same demodulise `reference_to`/
340
+ # `include` already use for their own type argument. `nil` when `on:`
341
+ # is omitted, matching every other optional field's "absent, not
342
+ # false" reading in this file.
343
+ def resolve_target(on) = on && Naming.demodulise(on)
344
+
230
345
  def add_aggregate_head(type, name, many:)
231
346
  @aggregate_heads ||= []
232
347
  target = Naming.demodulise(type)
@@ -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