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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/outbox.rb +7 -4
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
- data/lib/hecks/adapters/driven/postgres.rb +20 -17
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/assembly/marks.rb +13 -3
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
- data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
- data/lib/hecks/bluebook/model_check.rb +148 -17
- data/lib/hecks/forms/field_shape.rb +5 -3
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
- data/lib/hecks/fuzzing/era_boundary.rb +105 -0
- data/lib/hecks/fuzzing/form_census.rb +184 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
- data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
- data/lib/hecks/fuzzing/properties/guards.rb +103 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties.rb +23 -2
- data/lib/hecks/fuzzing/replay.rb +130 -20
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/self_consistency.rb +647 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
- data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
- data/lib/hecks/fuzzing/structural_skips.rb +146 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
- data/lib/hecks/fuzzing/value_generator.rb +55 -3
- data/lib/hecks/fuzzing.rb +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
- data/lib/hecks/naming.rb +70 -2
- data/lib/hecks/ports/persistence/append_only.rb +11 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +85 -5
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +302 -0
- data/lib/hecks/projections.rb +1 -0
- data/lib/hecks/projector/cli_projector.rb +29 -13
- data/lib/hecks/projector/exporter.rb +24 -0
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
- data/lib/hecks/query_specification/common/order_by.rb +9 -2
- data/lib/hecks/query_specification/common/where_clause.rb +15 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
- data/lib/hecks/runtime/command_rules/references.rb +75 -0
- data/lib/hecks/runtime/entity_element.rb +168 -16
- data/lib/hecks/runtime/entity_interpreter.rb +68 -3
- data/lib/hecks/runtime/interpreting.rb +16 -7
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
- data/lib/hecks/runtime/refusal_wording.rb +5 -2
- data/lib/hecks/runtime/registry.rb +12 -0
- data/lib/hecks/runtime/routing.rb +67 -2
- data/lib/hecks/runtime/saga_interpreter.rb +38 -1
- data/lib/hecks/runtime/value/coercion.rb +77 -52
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +7 -2
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- metadata +25 -2
|
@@ -14,14 +14,59 @@ module Hecks
|
|
|
14
14
|
|
|
15
15
|
def query_name = Naming.snake(@name)
|
|
16
16
|
|
|
17
|
-
# WHICH GATHERED
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
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
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
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
|
-
|
|
157
|
-
return unless declared
|
|
236
|
+
many = Array(@aggregate_heads).select { |head| head[:many] }
|
|
158
237
|
|
|
159
|
-
many
|
|
160
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
qualified
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
#
|
|
371
|
-
|
|
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
|
|
562
|
+
port_operation_events(aggregate.ports)
|
|
472
563
|
end
|
|
473
|
-
chapter_emits = bluebook.ports
|
|
564
|
+
chapter_emits = port_operation_events(bluebook.ports)
|
|
565
|
+
|
|
566
|
+
(aggregate_emits + chapter_emits).flatten.compact.uniq
|
|
567
|
+
end
|
|
474
568
|
|
|
475
|
-
|
|
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
|
-
|
|
40
|
-
return segment if words.empty?
|
|
40
|
+
return segment if segment.empty?
|
|
41
41
|
|
|
42
|
-
|
|
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
|