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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- 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/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 +31 -0
- 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 +214 -286
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- 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/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- 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 -115
- 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 +23 -2
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
require_relative "../invalid_value_generator"
|
|
2
|
+
require_relative "../value_generator"
|
|
3
|
+
require_relative "../../runtime/value"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Fuzzing
|
|
7
|
+
class SequenceGenerator
|
|
8
|
+
# THE ARGUMENT SHAPES THE RUBY/RUST DIVERGENCES WERE ACTUALLY FOUND
|
|
9
|
+
# THROUGH, injected on purpose, on every domain.
|
|
10
|
+
#
|
|
11
|
+
# Ten bugs in one QA session (BUG#7–#16, `QualityControl`'s own
|
|
12
|
+
# ledger) clustered in a handful of mechanisms — and several were
|
|
13
|
+
# only ever reachable because ONE domain happened to declare the
|
|
14
|
+
# argument that tripped them: `Roster::Roster.Mark`'s own `to:`
|
|
15
|
+
# collided with the dispatcher's routing `to:` (BUG#7), a present-
|
|
16
|
+
# but-null `to:` was misread as a routing envelope (BUG#16), a blank
|
|
17
|
+
# creating identity minted a phantom aggregate in Rust (BUG#15), a
|
|
18
|
+
# single-field closed-set argument offered as bare `null` refused
|
|
19
|
+
# with different KINDS on the two engines (BUG#14). No other domain
|
|
20
|
+
# in the rotation could ever have found any of those, because
|
|
21
|
+
# nothing generated ever produced the shape. This module produces
|
|
22
|
+
# them — for a configurable fraction of command steps, chosen and
|
|
23
|
+
# parameterised from the SAME seeded RNG the rest of the generator
|
|
24
|
+
# already draws from, so `generate(domain, seed:, steps:,
|
|
25
|
+
# adversarial:)` stays exactly as reproducible per seed as it was.
|
|
26
|
+
#
|
|
27
|
+
# OPT-IN, BY CONSTRUCTION. `adversarial: 0.0` (the default) returns
|
|
28
|
+
# before drawing a single random number, so every pinned seed in
|
|
29
|
+
# spec/fuzzing, spec/rust_conformance_fuzz_spec.rb, bin/fuzz and
|
|
30
|
+
# bin/generate produces byte-for-byte what it produced before this
|
|
31
|
+
# module existed. `bin/qa_sweep` turns it on (reading
|
|
32
|
+
# `QualityControlDials::ADVERSARIAL_FRACTION`) — that script is the
|
|
33
|
+
# one place a mutated step's own divergence is a FINDING rather than
|
|
34
|
+
# a red CI gate.
|
|
35
|
+
#
|
|
36
|
+
# ONE MUTATION PER STEP, applied in `StepBuilder#build_command_step`
|
|
37
|
+
# after the arguments and identity are built and BEFORE the step's
|
|
38
|
+
# own inline dispatch — so the generator's own `known_ids` tracking
|
|
39
|
+
# sees what really happened, and so the returned step's `args` ARE
|
|
40
|
+
# the mutated bytes both `Fuzzing::Replay` (Ruby) and the compiled
|
|
41
|
+
# conformance binary (Rust) later receive. The step carries an
|
|
42
|
+
# `"adversarial"` key naming what was done (`mutation`, the bug
|
|
43
|
+
# class it exercises, and the exact sub-shape), which `bin/qa_sweep`
|
|
44
|
+
# prints on a FOUND SOMETHING report so the agent logging the Bug
|
|
45
|
+
# can name the mechanism, not guess it. Both replay paths ignore
|
|
46
|
+
# the key: `Replay.call` reads only verb/query/dry_run/args/role,
|
|
47
|
+
# and `kernel/cli.rs` reads step keys by name.
|
|
48
|
+
module Adversary
|
|
49
|
+
KINDS = %i[
|
|
50
|
+
routing_key
|
|
51
|
+
blank_identity
|
|
52
|
+
null_value_object
|
|
53
|
+
duplicate_entity_identity
|
|
54
|
+
omit_mapped_argument
|
|
55
|
+
refusal_precedence
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
# BUG#7/#16/#8 — `to`/`with` are `Dispatcher#dispatch`'s own
|
|
59
|
+
# routing keywords (Ruby's kwarg binding steals them from a flat
|
|
60
|
+
# args hash; Rust's `CommandInvocation::from_json` reads the same
|
|
61
|
+
# two names off the same object), `id` is the untyped identity
|
|
62
|
+
# fallback `ArgumentGate#refuse_unknown_arguments` exempts. Three
|
|
63
|
+
# shapes each: bare null (BUG#16), a scalar (BUG#7's out-of-range
|
|
64
|
+
# Integer among them), and the routing-shaped object.
|
|
65
|
+
ROUTING_KEYS = %w[to with id].freeze
|
|
66
|
+
ROUTING_SHAPES = %w[null scalar route].freeze
|
|
67
|
+
|
|
68
|
+
# BUG#15 — a creating command's own identity part, blank three ways.
|
|
69
|
+
BLANK_SHAPES = %w[empty whitespace null].freeze
|
|
70
|
+
|
|
71
|
+
# BUG#14 — a single-field value object as bare `null` and as `{}`.
|
|
72
|
+
VALUE_OBJECT_SHAPES = %w[null empty_object].freeze
|
|
73
|
+
|
|
74
|
+
# BUG#7/#8/#14's class — an unknown key, a type-mismatched declared
|
|
75
|
+
# key and an absent required key in the SAME step, so both engines
|
|
76
|
+
# have to pick which refusal wins; and each pair, so the ordering
|
|
77
|
+
# of any two is observable on a command too small for all three.
|
|
78
|
+
#
|
|
79
|
+
# THE SECOND ROW REACHES FURTHER DOWN `DISPATCH_ORDER`
|
|
80
|
+
# (lib/hecks/vocabulary.rb): `nonexistent` addresses an id nothing
|
|
81
|
+
# holds (a `hydrate`-stage NotFound), `lifecycle` aims a
|
|
82
|
+
# transition-guarded command at a real record (an
|
|
83
|
+
# `admissible_transition`-stage refusal, IF the record's state
|
|
84
|
+
# refuses it), `role` binds a caller whose role the command does
|
|
85
|
+
# not name (the `refuse_role_mismatch` stage no generated step
|
|
86
|
+
# had ever reached). Paired with an argument-stage fault each,
|
|
87
|
+
# so the ordering of an EARLY stage against a LATE one is
|
|
88
|
+
# observable — BUG#13 (ledger_ordering's own NOTES.md) was
|
|
89
|
+
# exactly an argument-invariant-vs-entity-existence ordering
|
|
90
|
+
# split, and nothing generated had ever asked the question on
|
|
91
|
+
# purpose.
|
|
92
|
+
PRECEDENCE_SHAPES = %w[
|
|
93
|
+
unknown+mismatch+absent unknown+absent unknown+mismatch mismatch+absent
|
|
94
|
+
nonexistent+mismatch nonexistent+unknown lifecycle+mismatch role+absent role+nonexistent
|
|
95
|
+
].freeze
|
|
96
|
+
|
|
97
|
+
# ITEM 2 OF THE DETECTION PLAN (ANGLE-5) — A DRAWN CALLER ON A
|
|
98
|
+
# ROLE-GATED COMMAND. `refuse_role_mismatch` is a `DISPATCH_ORDER`
|
|
99
|
+
# step both engines implement (`command_rules/authorization.rb`,
|
|
100
|
+
# `rust/src/kernel/repository.rs check_role`) and both replay doors
|
|
101
|
+
# already read (`Fuzzing::Replay` binds `Hecks.as_caller` from a
|
|
102
|
+
# step's `role:`/`actor_id:`; `kernel/cli.rs` reads the same two
|
|
103
|
+
# keys) — yet no generated step ever carried either key, so the
|
|
104
|
+
# check was dormant on every sweep ever run. Five shapes:
|
|
105
|
+
# matching the command's own role — the string fallback
|
|
106
|
+
# authorizes it on both engines
|
|
107
|
+
# mismatched another declared role (or none the domain
|
|
108
|
+
# knows) — Unauthorized on both
|
|
109
|
+
# absent_on_gated no caller at all on a gated command — the
|
|
110
|
+
# unchecked default, recorded so the step reads
|
|
111
|
+
# as a deliberate control, not an omission
|
|
112
|
+
# actor_known the role PLUS an actor this same sequence
|
|
113
|
+
# already granted it to (`Governance::
|
|
114
|
+
# RoleAssignment.Assign` succeeded earlier) —
|
|
115
|
+
# the real `holds_role?` lookup, both sides
|
|
116
|
+
# actor_unknown the role plus an actor nothing granted —
|
|
117
|
+
# `holds_role?` must refuse on both
|
|
118
|
+
# A SEPARATE LAYER FROM `KINDS`, with its own probability
|
|
119
|
+
# (`role_draw:` — `QualityControlDials::ROLE_DRAW_PROBABILITY`):
|
|
120
|
+
# a caller composes with any argument mutation above rather than
|
|
121
|
+
# competing with it for the one-mutation-per-step slot, and draws
|
|
122
|
+
# nothing from the RNG when off, so every pinned seed is
|
|
123
|
+
# byte-identical to before it existed.
|
|
124
|
+
CALLER_SHAPES = %w[matching mismatched absent_on_gated actor_known actor_unknown].freeze
|
|
125
|
+
GRANT_VERB = "Governance::RoleAssignment.Assign".freeze
|
|
126
|
+
UNKNOWN_ROLE = "Nobody the domain names".freeze
|
|
127
|
+
|
|
128
|
+
# BUG#11 — an entity command two or more hops deep. Not a mutation
|
|
129
|
+
# of arguments but a PREFERENCE (picker.rb weights these up when
|
|
130
|
+
# adversarial) plus an addressing coin: flat one-head-per-hop args
|
|
131
|
+
# (what every other generated entity step uses) or the routed
|
|
132
|
+
# `to: { aggregate:, entities: [...] }` envelope BUG#11's own fix
|
|
133
|
+
# was scoped to. The step's metadata reports the depth reached
|
|
134
|
+
# so a depth-3 domain is visibly exercised there.
|
|
135
|
+
DEEP_ENTITY_DEPTH = 2
|
|
136
|
+
DEEP_ENTITY_WEIGHT = 4
|
|
137
|
+
|
|
138
|
+
# BUG#13's mutation is only ever applicable on a step whose
|
|
139
|
+
# parent ALREADY holds an element this same sequence appended —
|
|
140
|
+
# a rare moment (an append has to have succeeded first, and most
|
|
141
|
+
# mutated appends are refused), so when it is on offer it is
|
|
142
|
+
# weighted up the same way the picker weights an unexercised
|
|
143
|
+
# verb: the opportunity is what is scarce, not the kind.
|
|
144
|
+
DUPLICATE_IDENTITY_WEIGHT = 3
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def adversarial? = @adversarial.positive?
|
|
149
|
+
|
|
150
|
+
# `[]` — and NO RNG DRAW — when adversarial mode is off. Otherwise
|
|
151
|
+
# the deep-entity addressing note (every deep step, when
|
|
152
|
+
# adversarial), then with probability `@adversarial` exactly one
|
|
153
|
+
# mutation among those applicable to THIS step's own command.
|
|
154
|
+
def adversarial_mutations!(args, entry, catalog)
|
|
155
|
+
return [] unless adversarial?
|
|
156
|
+
|
|
157
|
+
mutations = []
|
|
158
|
+
mutations << deep_entity_addressing!(args, entry) if (entry[:chain] || []).size >= DEEP_ENTITY_DEPTH
|
|
159
|
+
return mutations if @random.rand >= @adversarial
|
|
160
|
+
|
|
161
|
+
applicable = KINDS.select { |kind| send(:"#{kind}_applicable?", args, entry, catalog) }
|
|
162
|
+
return mutations if applicable.empty?
|
|
163
|
+
|
|
164
|
+
weighted = applicable.flat_map { |kind| [kind] * (kind == :duplicate_entity_identity ? DUPLICATE_IDENTITY_WEIGHT : 1) }
|
|
165
|
+
mutations << send(:"apply_#{weighted.sample(random: @random)}!", args, entry, catalog)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# ── BUG#11: depth ≥ 2 entity command, flat or routed addressing ──
|
|
169
|
+
|
|
170
|
+
def deep_entity_addressing!(args, entry)
|
|
171
|
+
depth = entry[:chain].size
|
|
172
|
+
routed = @random.rand(2).zero?
|
|
173
|
+
note = { "mutation" => "deep_entity", "bug" => "BUG#11", "depth" => depth,
|
|
174
|
+
"addressing" => routed ? "routed" : "flat" }
|
|
175
|
+
return note unless routed
|
|
176
|
+
|
|
177
|
+
heads = [entry[:aggregate], *entry[:chain]].map { |construct| (construct.identified_by || :id).to_s }
|
|
178
|
+
scalars = heads.map { |head| ValueGenerator.scalar_of(args[head]) }
|
|
179
|
+
# The heads leave the flat args — this is the CLEAN routed
|
|
180
|
+
# caller BUG#11's own spec pins (`to: {...}, note: {...}`) —
|
|
181
|
+
# unless the command itself declares an attribute of that name
|
|
182
|
+
# (chess's `Piece.Move` declares `id` as a fact too), which
|
|
183
|
+
# stays because dropping it would be a different mutation.
|
|
184
|
+
heads.each { |head| args.delete(head) unless entry[:command].attribute(head) }
|
|
185
|
+
args["to"] = { "aggregate" => scalars.first, "entities" => scalars.drop(1) }
|
|
186
|
+
note
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# ── BUG#7/#16/#8: an undeclared routing/identity key on flat args ──
|
|
190
|
+
|
|
191
|
+
# Not on a step already carrying a routed `to:` from
|
|
192
|
+
# `deep_entity_addressing!` — overwriting that envelope would
|
|
193
|
+
# leave the deep-entity note claiming an addressing the args no
|
|
194
|
+
# longer have.
|
|
195
|
+
def routing_key_applicable?(args, _entry, _catalog) = !args.key?("to")
|
|
196
|
+
|
|
197
|
+
def apply_routing_key!(args, entry, _catalog)
|
|
198
|
+
key = ROUTING_KEYS.sample(random: @random)
|
|
199
|
+
shape = ROUTING_SHAPES.sample(random: @random)
|
|
200
|
+
args[key] =
|
|
201
|
+
case shape
|
|
202
|
+
when "null" then nil
|
|
203
|
+
when "scalar" then routing_scalar(args, entry)
|
|
204
|
+
else routing_object(args, entry)
|
|
205
|
+
end
|
|
206
|
+
{ "mutation" => "routing_key", "bug" => "BUG#7/#16/#8", "key" => key, "shape" => shape,
|
|
207
|
+
"declared" => !entry[:command].attribute(key).nil? }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A real-looking id (this step's own parent, so a routed envelope
|
|
211
|
+
# can actually resolve), BUG#7's own out-of-range Integer, or a
|
|
212
|
+
# minted id nothing holds.
|
|
213
|
+
def routing_scalar(args, entry)
|
|
214
|
+
case @random.rand(3)
|
|
215
|
+
when 0 then parent_scalar_of(args, entry)
|
|
216
|
+
when 1 then ValueGenerator::INTEGER_EDGE_CASES.sample(random: @random)
|
|
217
|
+
else ValueGenerator.random_id(@random)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def routing_object(args, entry)
|
|
222
|
+
entities = (entry[:chain] || []).map { |piece| ValueGenerator.scalar_of(args[(piece.identified_by || :id).to_s]) }
|
|
223
|
+
# Half the time one identity too many — a depth the verb does
|
|
224
|
+
# not have, which `Routing.envelope`'s `entity_depth` check and
|
|
225
|
+
# Rust's own envelope parser must both refuse the same way.
|
|
226
|
+
entities << ValueGenerator.random_id(@random) if @random.rand(2).zero?
|
|
227
|
+
{ "aggregate" => parent_scalar_of(args, entry), "entities" => entities }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def parent_scalar_of(args, entry)
|
|
231
|
+
key = (entry[:aggregate].identified_by || :id).to_s
|
|
232
|
+
args.key?(key) ? ValueGenerator.scalar_of(args[key]) : identity_scalar_of(entry[:aggregate], args)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# ── BUG#15: a blank identity part on a creating step ──────────────
|
|
236
|
+
|
|
237
|
+
def blank_identity_applicable?(args, entry, catalog) = blank_identity_targets(args, entry, catalog).any?
|
|
238
|
+
|
|
239
|
+
# The identity heads THIS step supplies itself: a creating
|
|
240
|
+
# aggregate command's own (every part of a composite), and an
|
|
241
|
+
# append's caller-supplied entity identity arguments (an entity is
|
|
242
|
+
# "created" by its append, and BUG#15's blank-identity question
|
|
243
|
+
# applies there too).
|
|
244
|
+
def blank_identity_targets(args, entry, catalog)
|
|
245
|
+
targets = []
|
|
246
|
+
if entry[:entity].nil? && entry[:command].creates?
|
|
247
|
+
aggregate = entry[:aggregate]
|
|
248
|
+
heads = composite_identity?(aggregate) ? aggregate.identity_heads : [aggregate.identified_by || :id]
|
|
249
|
+
targets.concat(heads.map(&:to_s))
|
|
250
|
+
end
|
|
251
|
+
populator = populator_for_entry(catalog, entry)
|
|
252
|
+
targets.concat(populator[:identity_arguments].map(&:to_s)) if populator
|
|
253
|
+
targets.uniq.select { |head| args.key?(head) }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def apply_blank_identity!(args, entry, catalog)
|
|
257
|
+
head = blank_identity_targets(args, entry, catalog).sample(random: @random)
|
|
258
|
+
shape = BLANK_SHAPES.sample(random: @random)
|
|
259
|
+
blank = shape == "empty" ? "" : " "
|
|
260
|
+
args[head] =
|
|
261
|
+
if shape == "null" then nil
|
|
262
|
+
elsif args[head].is_a?(Hash) then args[head].transform_values { blank }
|
|
263
|
+
else blank
|
|
264
|
+
end
|
|
265
|
+
{ "mutation" => "blank_identity", "bug" => "BUG#15", "argument" => head, "shape" => shape }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# ── BUG#14: a single-field value object as null / {} ─────────────
|
|
269
|
+
|
|
270
|
+
def null_value_object_applicable?(args, entry, _catalog) = value_object_targets(args, entry).any?
|
|
271
|
+
|
|
272
|
+
def value_object_targets(args, entry)
|
|
273
|
+
aggregate = entry[:aggregate]
|
|
274
|
+
entry[:command].attributes.reject { |attribute| attribute.list? || attribute.reference? }
|
|
275
|
+
.select { |attribute| args.key?(attribute.name.to_s) }
|
|
276
|
+
.filter_map do |attribute|
|
|
277
|
+
value_object = Runtime::Value.value_object_for(aggregate, attribute.type.to_s)
|
|
278
|
+
[attribute, value_object] if value_object&.sole_attribute
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def apply_null_value_object!(args, entry, _catalog)
|
|
283
|
+
targets = value_object_targets(args, entry)
|
|
284
|
+
closed = targets.select { |_, value_object| value_object.closed_set? }
|
|
285
|
+
attribute, value_object = (closed.empty? ? targets : closed).sample(random: @random)
|
|
286
|
+
shape = VALUE_OBJECT_SHAPES.sample(random: @random)
|
|
287
|
+
args[attribute.name.to_s] = shape == "null" ? nil : {}
|
|
288
|
+
{ "mutation" => "null_value_object", "bug" => "BUG#14", "argument" => attribute.name.to_s,
|
|
289
|
+
"value_object" => value_object.hecks_name, "closed_set" => value_object.closed_set? == true,
|
|
290
|
+
"shape" => shape }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# ── BUG#13: an entity identity this sequence already appended ────
|
|
294
|
+
|
|
295
|
+
def duplicate_entity_identity_applicable?(args, entry, catalog) = duplicate_identity_pool(args, entry, catalog).any?
|
|
296
|
+
|
|
297
|
+
def duplicate_identity_pool(args, entry, catalog)
|
|
298
|
+
populator = populator_for_entry(catalog, entry)
|
|
299
|
+
return [] unless populator && populator[:identity_arguments].any?
|
|
300
|
+
|
|
301
|
+
@appended_identities[append_pool_key(populator, args)]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def apply_duplicate_entity_identity!(args, entry, catalog)
|
|
305
|
+
populator = populator_for_entry(catalog, entry)
|
|
306
|
+
tuple = duplicate_identity_pool(args, entry, catalog).sample(random: @random)
|
|
307
|
+
args.merge!(tuple)
|
|
308
|
+
{ "mutation" => "duplicate_entity_identity", "bug" => "BUG#13", "entity" => populator[:entity].hecks_name,
|
|
309
|
+
"composite" => populator[:identity_arguments].size > 1, "identity" => tuple }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# ── BUG#12: a mapped/declared attribute left out of the payload ──
|
|
313
|
+
|
|
314
|
+
def omit_mapped_argument_applicable?(args, entry, _catalog) = mapped_argument_targets(args, entry).any?
|
|
315
|
+
|
|
316
|
+
# For an append: the arguments its `append:` mapping sources (the
|
|
317
|
+
# element's own declared fields). For a plain creating command:
|
|
318
|
+
# every declared non-identity attribute. Never an identity head —
|
|
319
|
+
# that is `blank_identity`'s question, not this one.
|
|
320
|
+
def mapped_argument_targets(args, entry)
|
|
321
|
+
command = entry[:command]
|
|
322
|
+
heads = identity_heads_of(entry)
|
|
323
|
+
mapped = command.mutations.select { |mutation| mutation.op == :append }
|
|
324
|
+
.flat_map { |mutation| mutation.source.values.grep(Symbol).map(&:to_s) }
|
|
325
|
+
if mapped.empty? && !entry.key?(:entity) && command.creates?
|
|
326
|
+
mapped = command.attributes.map { |attribute| attribute.name.to_s }
|
|
327
|
+
end
|
|
328
|
+
command.attributes.select do |attribute|
|
|
329
|
+
name = attribute.name.to_s
|
|
330
|
+
mapped.include?(name) && args.key?(name) && !heads.include?(name)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def apply_omit_mapped_argument!(args, entry, _catalog)
|
|
335
|
+
attribute = mapped_argument_targets(args, entry).sample(random: @random)
|
|
336
|
+
args.delete(attribute.name.to_s)
|
|
337
|
+
{ "mutation" => "omit_mapped_argument", "bug" => "BUG#12", "argument" => attribute.name.to_s,
|
|
338
|
+
"optional" => attribute.optional? == true }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# ── BUG#7/#8/#14: unknown + mismatched + absent, in one step ─────
|
|
342
|
+
|
|
343
|
+
def refusal_precedence_applicable?(args, entry, _catalog)
|
|
344
|
+
precedence_shapes_for(args, entry).any?
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# EVERY SHAPE WHOSE EVERY PART THIS STEP CAN CARRY. `nonexistent`
|
|
348
|
+
# and `lifecycle` need a command that ACTS on a record (a creating
|
|
349
|
+
# step has no addressed id to point elsewhere, and no state to be
|
|
350
|
+
# in) and flat addressing (`deep_entity_addressing!`'s routed `to:`
|
|
351
|
+
# envelope owns the ids then); `lifecycle` additionally needs a
|
|
352
|
+
# command some guard actually watches; `role` needs a declared
|
|
353
|
+
# `role` to mismatch against.
|
|
354
|
+
def precedence_shapes_for(args, entry)
|
|
355
|
+
can = {
|
|
356
|
+
"mismatch" => corruptible_attributes(args, entry).any?,
|
|
357
|
+
"absent" => droppable_required_attributes(args, entry).any?,
|
|
358
|
+
"unknown" => true,
|
|
359
|
+
"nonexistent" => acts_on_record?(args, entry),
|
|
360
|
+
"lifecycle" => acts_on_record?(args, entry) && transition_guarded?(entry),
|
|
361
|
+
"role" => !entry[:command].role.to_s.empty?
|
|
362
|
+
}
|
|
363
|
+
PRECEDENCE_SHAPES.select { |shape| shape.split("+").all? { |part| can.fetch(part) } }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def acts_on_record?(args, entry) = !entry[:command].creates? && !args.key?("to")
|
|
367
|
+
|
|
368
|
+
def transition_guarded?(entry)
|
|
369
|
+
command = entry[:command]
|
|
370
|
+
owner = entry.key?(:entity) ? entry[:entity] : entry[:aggregate]
|
|
371
|
+
return true if command.from
|
|
372
|
+
return false unless owner.respond_to?(:lifecycle)
|
|
373
|
+
|
|
374
|
+
owner.lifecycle&.transitions_for(command.hecks_name)&.any? || false
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def apply_refusal_precedence!(args, entry, catalog)
|
|
378
|
+
corruptible = corruptible_attributes(args, entry)
|
|
379
|
+
droppable = droppable_required_attributes(args, entry)
|
|
380
|
+
wanted = precedence_shapes_for(args, entry).sample(random: @random).split("+")
|
|
381
|
+
detail = { "mutation" => "refusal_precedence", "bug" => "BUG#7/#8/#14" }
|
|
382
|
+
applied = []
|
|
383
|
+
|
|
384
|
+
if wanted.include?("absent")
|
|
385
|
+
dropped = droppable.sample(random: @random)
|
|
386
|
+
args.delete(dropped.name.to_s)
|
|
387
|
+
detail["absent"] = dropped.name.to_s
|
|
388
|
+
applied << "absent"
|
|
389
|
+
corruptible -= [dropped]
|
|
390
|
+
end
|
|
391
|
+
if wanted.include?("mismatch") && corruptible.any?
|
|
392
|
+
attribute = corruptible.sample(random: @random)
|
|
393
|
+
args[attribute.name.to_s] = InvalidValueGenerator.corrupt(attribute, entry[:aggregate], random: @random)
|
|
394
|
+
detail["mismatched"] = attribute.name.to_s
|
|
395
|
+
applied << "mismatch"
|
|
396
|
+
end
|
|
397
|
+
if wanted.include?("unknown")
|
|
398
|
+
name, value = InvalidValueGenerator.undeclared_argument(random: @random)
|
|
399
|
+
args[name] = value
|
|
400
|
+
detail["unknown"] = name
|
|
401
|
+
applied << "unknown"
|
|
402
|
+
end
|
|
403
|
+
apply_late_stage_parts!(wanted, args, entry, detail, applied, catalog)
|
|
404
|
+
# Reported as what was ACTUALLY done — a command with a single
|
|
405
|
+
# attribute cannot carry both a dropped and a corrupted one.
|
|
406
|
+
detail.merge("shape" => applied.sort.join("+"))
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# The three parts that reach PAST the argument gate — see
|
|
410
|
+
# `PRECEDENCE_SHAPES`' own second row. `nonexistent` re-addresses
|
|
411
|
+
# the step's LAST hop (the entity element for an entity command,
|
|
412
|
+
# the aggregate itself otherwise) to an id nothing holds;
|
|
413
|
+
# `lifecycle` mutates nothing (the record's own state is what
|
|
414
|
+
# refuses, or doesn't) and is recorded so the pairing is visible;
|
|
415
|
+
# `role` parks a mismatched caller for `StepBuilder` to bind
|
|
416
|
+
# around this one dispatch.
|
|
417
|
+
def apply_late_stage_parts!(wanted, args, entry, detail, applied, catalog)
|
|
418
|
+
if wanted.include?("nonexistent")
|
|
419
|
+
piece = (entry[:chain] || []).last || entry[:aggregate]
|
|
420
|
+
head = (piece.identified_by || :id).to_s
|
|
421
|
+
args[head] = identity_shaped(piece, piece.identified_by, ValueGenerator.random_id(@random), entry[:aggregate])
|
|
422
|
+
detail["nonexistent"] = head
|
|
423
|
+
applied << "nonexistent"
|
|
424
|
+
end
|
|
425
|
+
if wanted.include?("lifecycle")
|
|
426
|
+
detail["lifecycle"] = entry[:command].from || "transition-guarded"
|
|
427
|
+
applied << "lifecycle"
|
|
428
|
+
end
|
|
429
|
+
return unless wanted.include?("role")
|
|
430
|
+
|
|
431
|
+
@precedence_caller = { "role" => other_role(entry[:command].role.to_s, catalog) }
|
|
432
|
+
detail["role"] = @precedence_caller["role"]
|
|
433
|
+
applied << "role"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# ── ANGLE-5: a drawn caller on a role-gated command ──────────────
|
|
437
|
+
|
|
438
|
+
def role_draw? = @role_draw.positive?
|
|
439
|
+
|
|
440
|
+
# `[caller, note]` — `caller` is the `{"role" => …, "actor_id" =>
|
|
441
|
+
# …}` pair (or nil for the unchecked control) `StepBuilder` binds
|
|
442
|
+
# around the step's one dispatch and writes onto the step itself;
|
|
443
|
+
# `note` rides in the step's `"adversarial"` metadata. A
|
|
444
|
+
# `refusal_precedence` mutation that already parked a caller for
|
|
445
|
+
# this step wins outright (its whole point is that pairing); an
|
|
446
|
+
# ungated command draws nothing (there is no role to match or
|
|
447
|
+
# mismatch), and with the draw off nothing is drawn at all.
|
|
448
|
+
def caller_draw!(entry, catalog)
|
|
449
|
+
if @precedence_caller
|
|
450
|
+
caller = @precedence_caller
|
|
451
|
+
@precedence_caller = nil
|
|
452
|
+
return [caller, nil]
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
role = entry[:command].role.to_s
|
|
456
|
+
return [nil, nil] if !role_draw? || role.empty? || @random.rand >= @role_draw
|
|
457
|
+
|
|
458
|
+
shapes = CALLER_SHAPES.dup
|
|
459
|
+
shapes.delete("actor_known") if @granted[role].empty?
|
|
460
|
+
shape = shapes.sample(random: @random)
|
|
461
|
+
caller = caller_for_shape(shape, role, catalog)
|
|
462
|
+
note = { "mutation" => "caller_role", "angle" => "ANGLE-5", "shape" => shape, "gated_role" => role }
|
|
463
|
+
[caller, caller ? note.merge(caller) : note]
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def caller_for_shape(shape, role, catalog)
|
|
467
|
+
case shape
|
|
468
|
+
when "matching" then { "role" => role }
|
|
469
|
+
when "mismatched" then { "role" => other_role(role, catalog) }
|
|
470
|
+
when "actor_known" then { "role" => role, "actor_id" => @granted[role].sample(random: @random) }
|
|
471
|
+
when "actor_unknown" then { "role" => role, "actor_id" => ValueGenerator.random_id(@random) }
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Another role the domain itself declares, when it has one — a
|
|
476
|
+
# real "wrong hat", the more interesting mismatch — else a role no
|
|
477
|
+
# bluebook names at all.
|
|
478
|
+
def other_role(role, catalog)
|
|
479
|
+
others = catalog[:roles] - [role]
|
|
480
|
+
others.empty? ? UNKNOWN_ROLE : others.sample(random: @random)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def corruptible_attributes(args, entry)
|
|
484
|
+
entry[:command].attributes.reject(&:list?).select { |attribute| args.key?(attribute.name.to_s) }
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def droppable_required_attributes(args, entry)
|
|
488
|
+
heads = identity_heads_of(entry)
|
|
489
|
+
entry[:command].attributes.reject(&:optional?).select do |attribute|
|
|
490
|
+
args.key?(attribute.name.to_s) && !heads.include?(attribute.name.to_s)
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# A GRANT AIMED AT A ROLE SOME COMMAND ACTUALLY DECLARES. Left to
|
|
495
|
+
# `ValueGenerator`, `Assign`'s `role_name` is random text
|
|
496
|
+
# ("hotel"), which no command is gated on — so a granted actor
|
|
497
|
+
# could never satisfy `holds_role?` for anything, and the
|
|
498
|
+
# `actor_known` shape above would be unreachable by construction.
|
|
499
|
+
# With the draw on, every generated grant names one of the
|
|
500
|
+
# domain's own declared roles instead; with it off, nothing here
|
|
501
|
+
# runs (no RNG draw, no change to the args).
|
|
502
|
+
def steer_grant!(args, entry, catalog)
|
|
503
|
+
return unless role_draw? && entry[:verb] == GRANT_VERB && catalog[:roles].any?
|
|
504
|
+
return unless args.key?("role_name")
|
|
505
|
+
|
|
506
|
+
args["role_name"] = { "value" => catalog[:roles].sample(random: @random) }
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# ── shared ───────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
def populator_for_entry(catalog, entry)
|
|
512
|
+
owner = entry.key?(:entity) ? entry[:entity] : entry[:aggregate]
|
|
513
|
+
catalog[:populators].find { |p| p[:command].equal?(entry[:command]) && p[:owner].equal?(owner) }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Every identity head this step addresses by: the aggregate's own
|
|
517
|
+
# (plus the untyped `id` fallback) and one per entity hop.
|
|
518
|
+
def identity_heads_of(entry)
|
|
519
|
+
heads = entry[:aggregate].identity_heads.map(&:to_s) + ["id"]
|
|
520
|
+
(entry[:chain] || []).each { |piece| heads.concat(piece.identity_heads.map(&:to_s)) }
|
|
521
|
+
heads.uniq
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|
|
@@ -26,16 +26,7 @@ module Hecks
|
|
|
26
26
|
queries << { verb: "#{domain_name}::#{aggregate.hecks_name}.#{query.name}",
|
|
27
27
|
query: query, aggregate: aggregate }
|
|
28
28
|
end
|
|
29
|
-
aggregate
|
|
30
|
-
entity.commands.each do |command|
|
|
31
|
-
entity_commands << { verb: "#{domain_name}::#{aggregate.hecks_name}.#{entity.hecks_name}.#{command.hecks_name}",
|
|
32
|
-
command: command, aggregate: aggregate, entity: entity }
|
|
33
|
-
end
|
|
34
|
-
entity.queries.each do |query|
|
|
35
|
-
entity_queries << { verb: "#{domain_name}::#{aggregate.hecks_name}.#{entity.hecks_name}.#{query.name}",
|
|
36
|
-
query: query, aggregate: aggregate, entity: entity }
|
|
37
|
-
end
|
|
38
|
-
end
|
|
29
|
+
catalog_entities(domain_name, aggregate, entity_commands, entity_queries)
|
|
39
30
|
end
|
|
40
31
|
|
|
41
32
|
# THE BARE DOMAIN FORM — "Domain.report_name", no "::" — the
|
|
@@ -56,41 +47,114 @@ module Hecks
|
|
|
56
47
|
{ creating: creating, instance: instance, entity_commands: entity_commands,
|
|
57
48
|
queries: queries, entity_queries: entity_queries, read_models: read_models,
|
|
58
49
|
populators: populators(runtime),
|
|
50
|
+
# Every `role "..."` any command in the boot declares — the
|
|
51
|
+
# "wrong hat" pool the caller draw's `mismatched` shape picks
|
|
52
|
+
# from (adversary.rb `other_role`).
|
|
53
|
+
roles: (creating + instance + entity_commands).filter_map { |e| e[:command].role }
|
|
54
|
+
.map(&:to_s).reject(&:empty?).uniq.sort,
|
|
59
55
|
# Which aggregates this corpus can actually make one of — the ones
|
|
60
56
|
# `satisfiable?` is entitled to wait for.
|
|
61
57
|
creatable: creating.to_set { |entry| entry[:aggregate].hecks_name } }
|
|
62
58
|
end
|
|
63
59
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
60
|
+
# EVERY ENTITY, AT EVERY DEPTH — `Card` nested inside `Board`
|
|
61
|
+
# inside `Workspace` (qa/stress_domains/nested_pieces) walks in
|
|
62
|
+
# as `chain: [Board, Card]`, the exact hop list
|
|
63
|
+
# `EntityInterpreter#walk_entity_chain` resolves the dotted verb
|
|
64
|
+
# back into. Before this walk existed the catalog only ever read
|
|
65
|
+
# `aggregate.entities` one level down, so a two-hop entity
|
|
66
|
+
# command (BUG#11's whole class) could never be generated at all
|
|
67
|
+
# — the one shape the differential harness most needed to reach
|
|
68
|
+
# was structurally absent from every sequence it ever produced.
|
|
69
|
+
# `entity:` stays the LAST hop (what every existing reader means
|
|
70
|
+
# by "the entity"); `chain:` is the whole path.
|
|
71
|
+
#
|
|
72
|
+
# Entity QUERIES stay one hop deep, exactly as before — a nested
|
|
73
|
+
# entity's query has no established wire spelling this generator
|
|
74
|
+
# can vouch for, and nothing in the corpus declares one.
|
|
75
|
+
def catalog_entities(domain_name, aggregate, entity_commands, entity_queries)
|
|
76
|
+
each_entity_chain(aggregate) do |chain|
|
|
77
|
+
entity = chain.last
|
|
78
|
+
path = chain.map(&:hecks_name).join(".")
|
|
79
|
+
entity.commands.each do |command|
|
|
80
|
+
entity_commands << { verb: "#{domain_name}::#{aggregate.hecks_name}.#{path}.#{command.hecks_name}",
|
|
81
|
+
command: command, aggregate: aggregate, entity: entity, chain: chain }
|
|
82
|
+
end
|
|
83
|
+
next unless chain.size == 1
|
|
84
|
+
|
|
85
|
+
entity.queries.each do |query|
|
|
86
|
+
entity_queries << { verb: "#{domain_name}::#{aggregate.hecks_name}.#{path}.#{query.name}",
|
|
87
|
+
query: query, aggregate: aggregate, entity: entity }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Depth-first, parents before children, in declaration order — so
|
|
93
|
+
# the depth-1 entries land in `entity_commands` in EXACTLY the
|
|
94
|
+
# order they always did (a pinned seed's picker pool is the same
|
|
95
|
+
# pool it was), and a nested entity's own entries follow its
|
|
96
|
+
# parent's.
|
|
97
|
+
def each_entity_chain(owner, chain = [], &block)
|
|
98
|
+
owner.entities.each do |entity|
|
|
99
|
+
path = chain + [entity]
|
|
100
|
+
yield path
|
|
101
|
+
each_entity_chain(entity, path, &block)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Which command, on which owner, appends to which entity list — so a
|
|
106
|
+
# successful dispatch can predict the identity the element it just
|
|
107
|
+
# added landed on. Entity#identified_by is filled by `Array(current).size + 1`
|
|
67
108
|
# (CommandInterpreter#entity_element) when the append's own field
|
|
68
109
|
# mapping doesn't already assign it — the common case, predicted here.
|
|
69
110
|
# A domain whose append explicitly assigns identity through a mapped
|
|
70
111
|
# argument is covered too, without guessing: whatever value THIS
|
|
71
112
|
# generator supplied for that argument at dispatch time IS the
|
|
72
113
|
# identity, and gets recorded directly (see `record_outcome`).
|
|
114
|
+
#
|
|
115
|
+
# `owner_chain:` — `[]` for an aggregate-level append (`Board.AddList`,
|
|
116
|
+
# `Folder.AddSlip`), the entity path for an ENTITY-level one
|
|
117
|
+
# (`Board.AddCard` appending into `Board.cards`, owner_chain
|
|
118
|
+
# `[Board]`) — so `record_outcome` can key the appended element
|
|
119
|
+
# under the exact parent-plus-hops it landed beneath.
|
|
120
|
+
# `identity_arguments:` — EVERY identity head the mapping sources
|
|
121
|
+
# from a command argument (a composite entity identity has several),
|
|
122
|
+
# what the adversarial duplicate-identity mutation replays;
|
|
123
|
+
# `identity_argument:` stays the single-head reading the existing
|
|
124
|
+
# auto-mint prediction already keys on.
|
|
73
125
|
def populators(runtime)
|
|
74
126
|
runtime.registry.bluebooks.each_value.flat_map do |bluebook|
|
|
75
127
|
bluebook.aggregates.flat_map do |aggregate|
|
|
76
|
-
aggregate
|
|
77
|
-
|
|
78
|
-
next unless append
|
|
79
|
-
|
|
80
|
-
list_attribute = aggregate.attribute(append.target)
|
|
81
|
-
next unless list_attribute&.list?
|
|
82
|
-
|
|
83
|
-
entity = aggregate.entities.find { |candidate| candidate.hecks_name == list_attribute.type.to_s }
|
|
84
|
-
next unless entity
|
|
128
|
+
owners = [[aggregate, []]]
|
|
129
|
+
each_entity_chain(aggregate) { |chain| owners << [chain.last, chain] }
|
|
85
130
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
{ command: command, aggregate: aggregate, entity: entity,
|
|
89
|
-
identity_field: identity_field, identity_argument: mapped.is_a?(Symbol) ? mapped : nil }
|
|
131
|
+
owners.flat_map do |owner, chain|
|
|
132
|
+
owner.commands.filter_map { |command| populator_for(aggregate, owner, chain, command) }
|
|
90
133
|
end
|
|
91
134
|
end
|
|
92
135
|
end
|
|
93
136
|
end
|
|
137
|
+
|
|
138
|
+
def populator_for(aggregate, owner, chain, command)
|
|
139
|
+
append = command.mutations.find { |mutation| mutation.op == :append }
|
|
140
|
+
return unless append
|
|
141
|
+
|
|
142
|
+
list_attribute = owner.attribute(append.target)
|
|
143
|
+
return unless list_attribute&.list?
|
|
144
|
+
|
|
145
|
+
entity = owner.entities.find { |candidate| candidate.hecks_name == list_attribute.type.to_s }
|
|
146
|
+
return unless entity
|
|
147
|
+
|
|
148
|
+
identity_field = entity.identified_by
|
|
149
|
+
mapped = identity_field && append.source[identity_field]
|
|
150
|
+
identity_arguments = entity.identity_heads.filter_map do |head|
|
|
151
|
+
source = append.source[head]
|
|
152
|
+
source if source.is_a?(Symbol)
|
|
153
|
+
end
|
|
154
|
+
{ command: command, aggregate: aggregate, owner: owner, owner_chain: chain, entity: entity,
|
|
155
|
+
identity_field: identity_field, identity_argument: mapped.is_a?(Symbol) ? mapped : nil,
|
|
156
|
+
identity_arguments: identity_arguments }
|
|
157
|
+
end
|
|
94
158
|
end
|
|
95
159
|
end
|
|
96
160
|
end
|