hecks 2.7.0 → 2.8.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/memory.rb +1 -1
  4. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  5. data/lib/hecks/adapters/driven/prism.rb +1 -1
  6. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  7. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  8. data/lib/hecks/behaviors/expectations.rb +2 -2
  9. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  10. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  11. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  12. data/lib/hecks/bluebook/assembly.rb +1 -1
  13. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  14. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  15. data/lib/hecks/bluebook/capabilities.rb +2 -2
  16. data/lib/hecks/bluebook/chapter.rb +1 -1
  17. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  18. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  19. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  21. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  22. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  23. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
  26. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  27. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  28. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  29. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  30. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  31. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  32. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  33. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  34. data/lib/hecks/bluebook/model_check.rb +12 -13
  35. data/lib/hecks/cache_dir.rb +103 -0
  36. data/lib/hecks/cli/document.rb +104 -0
  37. data/lib/hecks/cli/ir.rb +47 -0
  38. data/lib/hecks/cli/mcp.rb +32 -0
  39. data/lib/hecks/cli/mcp_door.rb +461 -0
  40. data/lib/hecks/cli/model_check.rb +270 -0
  41. data/lib/hecks/cli/project_cli.rb +123 -0
  42. data/lib/hecks/cli/project_diagrams.rb +43 -0
  43. data/lib/hecks/cli/run.rb +219 -0
  44. data/lib/hecks/cli/smoke_test.rb +59 -0
  45. data/lib/hecks/cli/stores.rb +54 -0
  46. data/lib/hecks/cli.rb +166 -0
  47. data/lib/hecks/facade/handle.rb +2 -2
  48. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  50. data/lib/hecks/mcp_door_scope.rb +176 -0
  51. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  52. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
  53. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  54. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  55. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  56. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  57. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  58. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
  59. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  60. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  61. data/lib/hecks/projections/deploy/lambda.rb +3 -3
  62. data/lib/hecks/projections/reference.rb +7 -1
  63. data/lib/hecks/projector/exporter.rb +4 -4
  64. data/lib/hecks/projector/target.rb +1 -1
  65. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  66. data/lib/hecks/release/runner/commands.rb +68 -0
  67. data/lib/hecks/release/runner/console.rb +52 -0
  68. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  69. data/lib/hecks/release/runner/git.rb +48 -0
  70. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  71. data/lib/hecks/release/runner/preflight.rb +119 -0
  72. data/lib/hecks/release/runner/published.rb +55 -0
  73. data/lib/hecks/release/runner/tagger.rb +87 -0
  74. data/lib/hecks/release/runner.rb +244 -0
  75. data/lib/hecks/router/namespace_installer.rb +1 -1
  76. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  77. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  78. data/lib/hecks/runtime/entity_element.rb +1 -1
  79. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  80. data/lib/hecks/runtime/invocation.rb +1 -1
  81. data/lib/hecks/runtime/loader.rb +1 -1
  82. data/lib/hecks/runtime/outbox.rb +1 -1
  83. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  84. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  85. data/lib/hecks/runtime/reference_hop.rb +4 -4
  86. data/lib/hecks/runtime/registry/verification.rb +11 -11
  87. data/lib/hecks/runtime/registry.rb +8 -4
  88. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  89. data/lib/hecks/runtime/value/coercion.rb +3 -3
  90. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  91. data/lib/hecks/storehouse.rb +18 -12
  92. data/lib/hecks/version.rb +1 -1
  93. data/lib/hecks/vocabulary.rb +5 -0
  94. data/lib/hecks.rb +6 -1
  95. metadata +28 -58
  96. data/lib/hecks/bench/cli.rb +0 -74
  97. data/lib/hecks/bench/environment.rb +0 -100
  98. data/lib/hecks/bench/postgres_probe.rb +0 -41
  99. data/lib/hecks/bench/report.rb +0 -116
  100. data/lib/hecks/bench/ruby_runner.rb +0 -151
  101. data/lib/hecks/bench/run.rb +0 -55
  102. data/lib/hecks/bench/rust_runner.rb +0 -165
  103. data/lib/hecks/bench/stats.rb +0 -75
  104. data/lib/hecks/bench/suite.rb +0 -172
  105. data/lib/hecks/bench/workload.rb +0 -137
  106. data/lib/hecks/bench.rb +0 -28
  107. data/lib/hecks/codemod.rb +0 -443
  108. data/lib/hecks/corpus.rb +0 -479
  109. data/lib/hecks/doc/reference.rb +0 -585
  110. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  111. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  112. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  113. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  114. data/lib/hecks/fuzzing/differential.rb +0 -192
  115. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  116. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  117. data/lib/hecks/fuzzing/form_census.rb +0 -285
  118. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  119. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  120. data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
  121. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  122. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  123. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  124. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  125. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  126. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  127. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  128. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  129. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  130. data/lib/hecks/fuzzing/properties.rb +0 -267
  131. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  132. data/lib/hecks/fuzzing/replay.rb +0 -930
  133. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  134. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  135. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  136. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  137. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  138. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  139. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  140. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  141. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  142. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  143. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  144. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  145. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  146. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  147. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  148. data/lib/hecks/fuzzing.rb +0 -23
  149. data/lib/hecks/grammar/evolve.rb +0 -524
  150. data/lib/hecks/query_ir.rb +0 -482
@@ -1,344 +0,0 @@
1
- require_relative "../../bluebook/expression/resolver"
2
- require_relative "../invalid_value_generator"
3
- require_relative "../value_generator"
4
- require_relative "../../runtime/errors"
5
- require_relative "../../runtime/value"
6
- require_relative "../../naming"
7
-
8
- module Hecks
9
- module Fuzzing
10
- class SequenceGenerator
11
- # Turn a picked entry into a corpus step: generate its arguments,
12
- # occasionally malform exactly one of them, shape its identity, and
13
- # dispatch it for real.
14
- module StepBuilder
15
- private
16
-
17
- def build_query_step(runtime, entry)
18
- args = args_for(entry[:query].attributes, entry[:aggregate])
19
- bind_to_written_row!(args, entry)
20
- safe_call { runtime.query(entry[:verb], **symbolize(args)) }
21
- { "query" => entry[:verb], "args" => args }
22
- end
23
-
24
- # A report ask — the bare domain form `entry[:verb]` already carries
25
- # ("Domain.report_name", no "::"), so `Dispatcher#query` routes it
26
- # to the read model rather than an aggregate query. A `ReadModel`
27
- # has no declared `.attributes` the way a `Query` does — its own
28
- # argument surface is exactly one key, `reference_name`, and only
29
- # for a rooted model (`read_model_actionable?` already gated a
30
- # rootless one straight into eligibility with nothing to supply).
31
- # A bare scalar, not `identity_shaped` — `ReadModelInterpreter#
32
- # refuse_object_reference` explicitly rejects a Hash/Value offered
33
- # here (this is the one place in the whole generator where the
34
- # subject's own identity must not be wrapped the way a command
35
- # argument's would be).
36
- def build_read_model_step(runtime, entry)
37
- model = entry[:model]
38
- args = model.reference_target.nil? ? {} : { model.reference_name.to_s => pick_known(model.reference_target) }
39
-
40
- safe_call { runtime.query(entry[:verb], **symbolize(args)) }
41
- { "query" => entry[:verb], "args" => args }
42
- end
43
-
44
- # The adversarial layer sits here, and only here — after the step's
45
- # arguments and identity are fully built, before the one real
46
- # dispatch this generator makes to learn what the step did, and
47
- # before the step is returned as corpus data. That ordering is
48
- # the whole contract: the mutated `args` are what this generator's
49
- # own inline dispatch sees (so `known_ids` tracking reflects what
50
- # actually happened), and they are the bytes `Fuzzing::Replay`
51
- # later hands Ruby's runtime and the bytes `JSON.generate({steps:
52
- # ...})` hands the compiled Rust binary — one step, one payload,
53
- # both engines. Nothing downstream of this method can tell a
54
- # mutated step from an ordinary one except by reading the
55
- # `"adversarial"` metadata it carries (see adversary.rb).
56
- #
57
- # Two more draws sit right after the mutation, in a fixed order —
58
- # a caller (`caller_draw!`, adversary.rb: `role:`/`actor_id:` on
59
- # the step, bound around this one dispatch exactly the way
60
- # `Fuzzing::Replay` and `kernel/cli.rs` will later bind it) and
61
- # then the dry-run coin (`{"dry_run": verb}` instead of
62
- # `{"verb": verb}` — `Dispatcher#dry_run?` here, the same door on
63
- # both replay sides). Order matters for the seed contract: every
64
- # draw comes from the one `Random.new(seed)`, so the sequence of
65
- # draws per step is what makes a seed reproducible. Both are
66
- # off by default and draw nothing when off.
67
- def build_command_step(runtime, catalog, entry)
68
- args = args_for(entry[:command].attributes, entry[:aggregate])
69
- add_identity!(args, entry)
70
- steer_grant!(args, entry, catalog)
71
- mutations = adversarial_mutations!(args, entry, catalog)
72
- caller, caller_note = caller_draw!(entry, catalog)
73
- mutations << caller_note if caller_note
74
- @state_before = state_before(runtime, entry, args)
75
-
76
- step =
77
- if dry_run_draw?
78
- safe_call { as_caller(caller) { runtime.dry_run?(entry[:verb], **symbolize(args)) } }
79
- { "dry_run" => entry[:verb], "args" => args }
80
- else
81
- outcome = safe_call { as_caller(caller) { runtime.dispatch_flat(entry[:verb], symbolize(args)) } }
82
- if outcome
83
- record_outcome(catalog, entry, args)
84
- harvest_written_rows(runtime, catalog)
85
- @event_count += outcome.events.length
86
- end
87
- { "verb" => entry[:verb], "args" => args }
88
- end
89
- step.merge!(caller) if caller
90
- step["adversarial"] = mutations unless mutations.empty?
91
- step
92
- end
93
-
94
- # No RNG draw at all when the fraction is zero — the same "off is
95
- # byte-identical" contract `adversarial?` keeps.
96
- def dry_run_draw? = @dry_run.positive? && @random.rand < @dry_run
97
-
98
- # `Hecks.as_caller` for exactly this block, or a bare yield — the
99
- # same binding `Fuzzing::Replay` makes from the step's own keys
100
- # later, so the generator's inline dispatch and both replays see
101
- # one caller.
102
- def as_caller(caller, &)
103
- return yield unless caller
104
-
105
- Hecks.as_caller(role: caller["role"], actor_id: caller["actor_id"], &)
106
- end
107
-
108
- def args_for(attributes, aggregate)
109
- args = attributes.each_with_object({}) do |attribute, built|
110
- # An optional argument is sometimes not given, and that is an
111
- # ordinary payload rather than a damaged one — see
112
- # OPTIONAL_OMITTED_PROBABILITY for why this cannot live in
113
- # `malform` below and what it was costing while it did not
114
- # exist at all.
115
- next if attribute.optional? && @random.rand < SequenceGenerator::OPTIONAL_OMITTED_PROBABILITY
116
-
117
- if attribute.list?
118
- value = list_value_for(attribute, aggregate)
119
- # A list-of-entity command attribute has no real example
120
- # anywhere in this repo's domains — every entity-owned list is
121
- # populated via a per-element append command instead, never a
122
- # whole-list command argument — so `list_value_for` (below)
123
- # answers `nil` for one rather than guessing at an entity's own
124
- # shape, and this step still skips it exactly as it always
125
- # has. A list-of-value-object attribute (`ConsoleSettings::
126
- # Collection.ReplaceColumns`' own `columns`, `list_of(Column)`)
127
- # is the real case this covers —
128
- # `sets :columns` imports the owner aggregate's own declared
129
- # `list_of` attribute onto the command verbatim (Command
130
- # Builder#resolve_bare_set!), so it is a required, ordinary
131
- # top-level argument like any other, not an entity mutation.
132
- next if value.nil?
133
-
134
- built[attribute.name.to_s] = value
135
- else
136
- built[attribute.name.to_s] = ValueGenerator.value_for(attribute, aggregate, random: @random, known_ids: @known_ids)
137
- end
138
- end
139
-
140
- malform(args, attributes, aggregate)
141
- end
142
-
143
- # A `list_of` attribute's own value — an array of independently
144
- # generated elements, each shaped exactly the way a bare (non-list)
145
- # attribute of the same declared element type already is
146
- # (`ValueGenerator.value_for`), since `list_of(X)`'s own element
147
- # coercion is `X`'s ordinary shape repeated, not a different one
148
- # (`Attribute#type` is already unwrapped from `list_of(...)` at
149
- # declare time — the same fact `command_builder.rb`'s own comment
150
- # on `resolve_bare_set!` names). 0-3 elements — enough to exercise a
151
- # genuinely non-trivial replace without generating pathologically
152
- # large payloads every time. `nil`, not `[]`, when the element type
153
- # isn't a declared value object at all (an entity-typed list) — see
154
- # `args_for`'s own comment on why that case still skips rather than
155
- # guesses.
156
- def list_value_for(attribute, aggregate)
157
- value_object = Runtime::Value.value_object_for(aggregate, attribute.type.to_s)
158
- return nil unless value_object
159
-
160
- Array.new(@random.rand(0..3)) { ValueGenerator.value_for(attribute, aggregate, random: @random, known_ids: @known_ids) }
161
- end
162
-
163
- # One malformation at a time, and usually none. A step whose payload is
164
- # wrong in three ways only ever proves which check runs first ; wrong in
165
- # exactly one way names the check that fired. And the rate stays low on
166
- # purpose — a corrupted step is almost always refused, a sequence of
167
- # refusals reaches no state at all, and bin/fuzz already counts those as
168
- # silent rather than scoring them.
169
- def malform(args, attributes, aggregate)
170
- return args if args.empty? || @random.rand >= MALFORMED_ARGUMENT_PROBABILITY
171
-
172
- case @random.rand(3)
173
- when 0 then corrupt_one(args, attributes, aggregate)
174
- when 1 then drop_one(args, aggregate)
175
- else args.merge([InvalidValueGenerator.undeclared_argument(random: @random)].to_h)
176
- end
177
- end
178
-
179
- # Never the identity. A creating command with no id auto-mints one, and
180
- # a minted id is deliberately unreproducible — a random hex, never a
181
- # guessable counter — so dropping it manufactures a step whose outcome
182
- # cannot be replayed and says nothing about the runtime's behaviour.
183
- # Every step in the hand-written corpus
184
- # supplies an id for the same reason. Whether an auto-minted id ought to
185
- # be reproducible is a real question, but it is not one a payload fuzzer
186
- # can ask.
187
- def drop_one(args, aggregate)
188
- identity = (aggregate.identified_by || :id).to_s
189
- droppable = args.keys - [identity, "id"]
190
- return args if droppable.empty?
191
-
192
- args.reject { |name, _| name == droppable.sample(random: @random) }
193
- end
194
-
195
- def corrupt_one(args, attributes, aggregate)
196
- named = attributes.reject(&:list?).select { |attribute| args.key?(attribute.name.to_s) }
197
- return args if named.empty?
198
-
199
- attribute = named.sample(random: @random)
200
- args.merge(attribute.name.to_s => InvalidValueGenerator.corrupt(attribute, aggregate, random: @random))
201
- end
202
-
203
- def add_identity!(args, entry)
204
- aggregate = entry[:aggregate]
205
- parent_key = (aggregate.identified_by || :id).to_s
206
-
207
- if entry[:entity]
208
- parent_scalar = pick_known(aggregate.hecks_name)
209
- args[parent_key] = identity_shaped(aggregate, aggregate.identified_by, parent_scalar, aggregate)
210
- # One identity per hop, each drawn from the pool its own
211
- # parent-plus-hops landed elements in (`entity_pool_key`) —
212
- # a depth-1 chain draws exactly what it always did; a
213
- # `Board.Card` chain draws a Board under this Workspace, then
214
- # a Card under that Board. Flat args, one head per hop, is
215
- # the legacy addressing `EntityElement#locate_chain` reads
216
- # (`args[head]` per identity path); the routed `to: {
217
- # aggregate:, entities: [...] }` spelling is an adversarial
218
- # shape layered on top (adversary.rb), never the default.
219
- scalars = [parent_scalar]
220
- names = []
221
- entry[:chain].each do |piece|
222
- names << piece.hecks_name
223
- scalar = pick_entity_known(entity_pool_key(aggregate.hecks_name, names, scalars))
224
- args[(piece.identified_by || :id).to_s] = identity_shaped(piece, piece.identified_by, scalar, aggregate)
225
- scalars << scalar
226
- end
227
- elsif entry[:command].creates?
228
- # A composite identity (`identified_by` answering nil with more
229
- # than one declared path — Behaviour::Identified's own "a
230
- # composite has no single head" comment) supplies every one of
231
- # its parts as its own ordinary, individually-declared command
232
- # attribute already — `RoleAssignment::Assign` takes actor_id/
233
- # role_name/starts_at directly, `args_for` (above) already
234
- # generated all three. Forcing a synthetic top-level `id` here
235
- # too — this codebase's own fallback for the single-key and the
236
- # genuinely untyped (`identity_paths.empty?`, no `identified_by`
237
- # declared at all) cases — hands a composite creating command
238
- # an argument it never declared at all, refused every time as
239
- # unknown before this check existed (a creating `Assign`/`Grant`
240
- # step was never anything but refused). `identity_paths.empty?`
241
- # is the untyped default (falls all the way back to a minted
242
- # `:id` the runtime itself never declared as an attribute
243
- # either), which still needs exactly the old minting behavior.
244
- unless composite_identity?(aggregate)
245
- args[parent_key] ||= identity_shaped(aggregate, aggregate.identified_by, ValueGenerator.random_id(@random),
246
- aggregate)
247
- end
248
- else
249
- scalar = pick_known(aggregate.hecks_name)
250
- args[parent_key] = identity_shaped(aggregate, aggregate.identified_by, scalar, aggregate)
251
- end
252
- end
253
-
254
- # True only for a genuine multi-field identity — `identified_by`
255
- # returns nil both for a real composite (`identity_paths.size > 1`)
256
- # and for the untyped default with no identity declared at all
257
- # (`identity_paths.size == 0`, Behaviour::Identified's own
258
- # `Array(@identified_by)` fallback) ; only the first of those two
259
- # has its own parts already sitting in `args` as real, individually-
260
- # generated command attributes.
261
- def composite_identity?(aggregate) = aggregate.identified_by.nil? && aggregate.identity_paths.size > 1
262
-
263
- # A bare scalar id, shaped to match whatever `construct` itself
264
- # declares that identity field as. `Account::LedgerEntry` is addressed
265
- # by `sequence`, and `sequence` is declared as a value-object-typed
266
- # attribute (`LedgerSequence`), not a plain identifier — a fuzz run
267
- # that skipped this wrapping and dispatched a bare `"1"` had every
268
- # such step refused at the type gate (a bare scalar against a
269
- # value-object-typed identity is a TypeMismatch, not a NotFound), so
270
- # the wrapping is what lets a fuzz step address the record at
271
- # all. Left as a bare scalar when the construct declares no such
272
- # attribute at all — the default `:id` case, which really is untyped.
273
- def identity_shaped(construct, key, scalar, aggregate)
274
- return scalar unless key
275
-
276
- attribute = construct.attribute(key)
277
- return scalar unless attribute
278
-
279
- value_object = aggregate.value_object(attribute.type.to_s)
280
- return scalar unless value_object
281
-
282
- field = value_object.attributes.first
283
- return scalar unless field
284
-
285
- { field.name.to_s => coerce_scalar(field.type.to_s, scalar) }
286
- end
287
-
288
- def coerce_scalar(type_name, scalar)
289
- case type_name
290
- when "Integer" then scalar.to_i
291
- when "Float" then scalar.to_f
292
- else scalar.to_s
293
- end
294
- end
295
-
296
- def symbolize(args) = args.transform_keys(&:to_sym)
297
-
298
- # A step the runtime declines is not a generator failure — it simply did
299
- # not take effect, so nothing is recorded and the sequence carries on. The
300
- # step still goes into the corpus, because a refusal is an answer, and
301
- # its wording is pinned by the corpus.
302
- #
303
- # EvaluationError sits alongside the declared refusals deliberately : a
304
- # payload the interpreter cannot read is the domain declining it, and
305
- # bin/run already records exactly those in a corpus's refusals —
306
- # `positive? expects a number, got "lots"` is one of banking's. Anything
307
- # else still propagates and fails spec/fuzzing, which is what says the
308
- # generator built a step that breaks the interpreter for reasons that have
309
- # nothing to do with the domain declining a payload.
310
- def safe_call
311
- result = yield
312
- @last_outcome = "ok"
313
- result
314
- rescue *Hecks::Runtime::DOMAIN_REFUSALS, Hecks::Bluebook::Expression::EvaluationError => e
315
- @last_outcome = e.class.name.split("::").last
316
- nil
317
- end
318
-
319
- # Where the addressed aggregate stood just before this dispatch — its
320
- # lifecycle value, or `exists`/`absent` when it declares none. Read
321
- # straight off the repository, never through dispatch, and draws
322
- # nothing from the RNG, so it changes no generated byte. An identity
323
- # an adversarial mutation mangled past resolving is `?`.
324
- def state_before(runtime, entry, args)
325
- aggregate = entry[:aggregate]
326
- symbolic = symbolize(args)
327
- id = Runtime::Identity.of(aggregate, symbolic) || Runtime::Identity.from(aggregate, symbolic, :id)
328
- return "absent" unless id
329
-
330
- record = runtime.registry.repository(entry[:verb].split("::").first, aggregate).find(id)
331
- return "absent" unless record
332
-
333
- lifecycle = aggregate.lifecycle
334
- return "exists" unless lifecycle
335
-
336
- key = record.state.key?(lifecycle.field) ? lifecycle.field : lifecycle.field.to_s
337
- record.state[key].to_s
338
- rescue StandardError
339
- "?"
340
- end
341
- end
342
- end
343
- end
344
- end
@@ -1,311 +0,0 @@
1
- require "fileutils"
2
- require "tmpdir"
3
- require_relative "isolated_boot"
4
- require_relative "invalid_value_generator"
5
- require_relative "value_generator"
6
- require_relative "sequence_generator/catalog"
7
- require_relative "sequence_generator/picker"
8
- require_relative "sequence_generator/step_builder"
9
- require_relative "sequence_generator/outcome_tracker"
10
- require_relative "sequence_generator/query_binding"
11
- require_relative "sequence_generator/adversary"
12
-
13
- module Hecks
14
- module Fuzzing
15
- # A random-but-valid sequence of dispatches and queries, in the exact
16
- # `{name, note, steps}` shape `spec/corpus/*.json` already uses — so a
17
- # generated sequence replays as a corpus member, completely unchanged.
18
- # Boots a throwaway copy of
19
- # the domain and dispatches each candidate step for real as it builds the
20
- # sequence (not just synthesizing plausible-looking JSON) : the only way to
21
- # know whether a step actually reached a new state, or which id an
22
- # auto-minted entity landed on, is to run it and watch what happened.
23
- #
24
- # Single-call fuzzing mostly misses the bugs this project has actually
25
- # found — they needed state first (a saga leg acting on a transfer that
26
- # already exists, a reference pointing at a customer already registered).
27
- # So this tracks what it has created as it goes, the same way
28
- # spec/banking_state_machine_spec.rb's hand-written generator does, and
29
- # weights later steps toward acting on what already exists.
30
- #
31
- # One concern per file beside this one: what the domain offers
32
- # (sequence_generator/catalog.rb), which step to try next (picker.rb),
33
- # how a step is built and dispatched (step_builder.rb), what a
34
- # success taught us (outcome_tracker.rb), which stored values a query is
35
- # asked about (query_binding.rb), and — opt-in, `adversarial:` — the
36
- # argument shapes real Ruby/Rust divergences were found through
37
- # (adversary.rb).
38
- class SequenceGenerator
39
- include Catalog
40
- include Picker
41
- include StepBuilder
42
- include OutcomeTracker
43
- include QueryBinding
44
- include Adversary
45
-
46
- # Creating commands are always eligible ; weighting them heavier (not
47
- # exclusively — a domain with only one or two aggregates would starve
48
- # everything else) makes a sequence reach an actionable state sooner
49
- # instead of spending its early budget refusing "acts on nothing yet."
50
- CREATING_WEIGHT = 2
51
-
52
- # How often a payload is deliberately the wrong shape. Low, because a
53
- # refused step reaches no new state and a sequence of them is silent.
54
- MALFORMED_ARGUMENT_PROBABILITY = 0.12
55
-
56
- # How often an optional argument is simply not given — a fair coin,
57
- # because that is exactly what `optional:` means: present or absent,
58
- # both legal, neither the interesting case.
59
- #
60
- # This is not a malformation and must not be filed as one. `malform`
61
- # drops an argument too, but a dropped required argument is refused,
62
- # a refusal writes no record, and bin/fuzz counts it silent — so the
63
- # one outcome worth reaching (a stored record carrying a null, then
64
- # queried) was unreachable from that path by construction. Until
65
- # this existed no generated history contained a null at all, which
66
- # meant `query_answers_match_reference` — the differential that
67
- # diffs every native adapter against the reference interpreter — had
68
- # never once compared a nullable field. Four query bugs shipped in
69
- # exactly that blind spot (`ne:` with an empty string, array `in:`,
70
- # `ne:` against a null, `in` on a numeric field); the adapter
71
- # agreement gate had the identical hole and found a fourth bug the
72
- # hour it was closed.
73
- OPTIONAL_OMITTED_PROBABILITY = 0.5
74
-
75
- # How strongly an unexercised verb is preferred over one this sequence has
76
- # already dispatched. Random picking revisits the same handful of verbs and
77
- # leaves whole commands untouched for a whole run — which is the same
78
- # "reached no interesting state" problem the silent count reports, seen from
79
- # the generating end rather than the scoring end.
80
- UNEXERCISED_WEIGHT = 4
81
-
82
- # `adversarial:` — the fraction of generated command steps (0.0..1.0)
83
- # that get one deliberately adversarial argument mutation
84
- # (adversary.rb — the shapes BUG#7–#16 were found through). `0.0`,
85
- # the default, draws nothing extra from the seeded RNG, so a seed's
86
- # output is byte-for-byte what it was before the option existed;
87
- # any positive value is just as deterministic per seed, since every
88
- # choice the mutation makes comes from the same `Random.new(seed)`.
89
- #
90
- # `role_draw:` / `dry_run:` — two more opt-in fractions with the
91
- # identical contract (adversary.rb's `caller_draw!`, step_builder.rb's
92
- # `dry_run_draw?`): `0.0` draws nothing, so every pinned seed is
93
- # byte-for-byte what it was; `bin/qa_sweep` reads them from
94
- # `QualityControlDials::ROLE_DRAW_PROBABILITY`/`DRY_RUN_FRACTION`.
95
- #
96
- # `prefix:` / `favor:` — the two levers `CoverageCampaign` pulls
97
- # (coverage_campaign.rb has the why). `prefix:` is `{ "seed", "steps",
98
- # "favor", "prefix" }`: re-generate that seed's first `steps` attempts
99
- # (itself recursively prefixed) before this seed's own randomness
100
- # starts, so a seed can begin from state an earlier seed reached.
101
- # `favor:` names verbs the picker weights up. `nil`/`[]`, the
102
- # defaults, draw nothing extra and change nothing: every pinned seed
103
- # is byte-for-byte what it was.
104
- #
105
- # Generates one random-but-valid step sequence for `domain_path`, dispatching
106
- # each step for real against a throwaway boot as it builds it.
107
- #
108
- # @param domain_path [String] path to the domain directory to boot
109
- # @param seed [Integer] RNG seed; every draw this run makes is reproducible
110
- # from it
111
- # @param steps [Integer] number of generation attempts to make
112
- # @param adapter [Symbol] persistence adapter to boot with (default `:memory`)
113
- # @param adversarial [Float] fraction of command steps to mutate adversarially
114
- # (default `0.0`, drawing nothing extra)
115
- # @param role_draw [Float] fraction of gated commands to draw a caller for
116
- # (default `0.0`, drawing nothing extra)
117
- # @param dry_run [Float] fraction of command steps dispatched as dry runs
118
- # (default `0.0`, drawing nothing extra)
119
- # @param prefix [Hash, nil] another seed's own generation spec to replay
120
- # first (`{"seed" =>, "steps" =>, "favor" =>, "prefix" =>}`), or `nil`
121
- # @param favor [Array<String>, Array<Symbol>] verbs the picker weights up
122
- # @return [Array<Hash>] the generated step list, each a command, query, or
123
- # read-model step
124
- def self.generate(domain_path, seed:, steps:, **)
125
- new(domain_path, seed: seed, steps: steps, **).call
126
- end
127
-
128
- # The same generation, with what it reached — `coverage` is
129
- # `[[attempt_index, tuple], ...]` (`coverage_tuple`), `verbs` every
130
- # verb the booted catalog offered, so a campaign can tell a verb it
131
- # never hit from one that does not exist.
132
- Trace = Struct.new(:steps, :coverage, :verbs, keyword_init: true)
133
-
134
- # Generates one sequence exactly like `.generate`, but also returns the
135
- # coverage and verb data a campaign needs, which `.generate` discards.
136
- #
137
- # @param domain_path [String] path to the domain directory to boot
138
- # @param seed [Integer] RNG seed; every draw this run makes is reproducible
139
- # from it
140
- # @param steps [Integer] number of generation attempts to make
141
- # @return [Hecks::Fuzzing::SequenceGenerator::Trace] the generated steps,
142
- # the coverage tuples reached, and every verb the booted catalog offered
143
- def self.trace(domain_path, seed:, steps:, **)
144
- generator = new(domain_path, seed: seed, steps: steps, **)
145
- Trace.new(steps: generator.call, coverage: generator.coverage, verbs: generator.verbs)
146
- end
147
-
148
- # How strongly a `favor:` verb is preferred when it is eligible —
149
- # the same order of magnitude as an unexercised verb, so favor
150
- # steers without drowning out what this sequence has not touched.
151
- FAVOR_WEIGHT = 4
152
-
153
- attr_reader :coverage, :verbs
154
-
155
- # How many events the generated sequence actually produced — not
156
- # steps, not successful dispatches, but the sum of every Result#events
157
- # length across the run. This is the count bin/fuzz declares as the
158
- # script's own `expectations.events` claim: whatever was achieved
159
- # during generation becomes the claim a fresh replay of the same
160
- # script is held to. Zero means the sequence never
161
- # reached an interesting state — a fuzzer-effectiveness fact, not a
162
- # replay one.
163
- attr_reader :event_count
164
-
165
- # @param domain_path [String] path to the domain directory to boot
166
- # @param seed [Integer] RNG seed; every draw this run makes is reproducible
167
- # from it
168
- # @param steps [Integer] number of generation attempts `#call` will make
169
- # @param adapter [Symbol] persistence adapter to boot with
170
- # @param adversarial [Float] fraction of command steps to mutate adversarially
171
- # @param role_draw [Float] fraction of gated commands to draw a caller for
172
- # @param dry_run [Float] fraction of command steps dispatched as dry runs
173
- # @param prefix [Hash, nil] another seed's own generation spec to replay
174
- # first, or `nil`
175
- # @param favor [Array<String>, Array<Symbol>] verbs the picker weights up
176
- # @raise [ArgumentError] if `adversarial`, `role_draw`, or `dry_run` is not a
177
- # Numeric between 0.0 and 1.0
178
- def initialize(domain_path, seed:, steps:, adapter: :memory, adversarial: 0.0, role_draw: 0.0, dry_run: 0.0,
179
- prefix: nil, favor: [])
180
- { adversarial: adversarial, role_draw: role_draw, dry_run: dry_run }.each do |name, fraction|
181
- next if fraction.is_a?(Numeric) && fraction.between?(0, 1)
182
-
183
- raise ArgumentError, "#{name}: must be a fraction between 0.0 and 1.0, got #{fraction.inspect}"
184
- end
185
-
186
- @domain_path = domain_path
187
- @seed = seed
188
- @step_count = steps
189
- @adapter = adapter
190
- @adversarial = adversarial.to_f
191
- @role_draw = role_draw.to_f
192
- @dry_run = dry_run.to_f
193
- restart_random(seed)
194
- @known_ids = Hash.new { |h, k| h[k] = [] }
195
- @entity_known_ids = Hash.new { |h, k| h[k] = [] }
196
- @appended_identities = Hash.new { |h, k| h[k] = [] }
197
- # "Domain::Aggregate" => the rows this sequence has stored that a
198
- # query filters on (query_binding.rb).
199
- @written_rows = Hash.new { |h, k| h[k] = [] }
200
- # Role => [actor ids] this sequence's own successful
201
- # `Governance::RoleAssignment.Assign` steps granted — what the
202
- # `actor_known` caller shape draws from (adversary.rb).
203
- @granted = Hash.new { |h, k| h[k] = [] }
204
- @precedence_caller = nil
205
- @exercised = Set.new
206
- @event_count = 0
207
- @prefix = prefix
208
- @favor = Array(favor)
209
- @own_favor = @favor
210
- @coverage = []
211
- @verbs = []
212
- @attempt = 0
213
- end
214
-
215
- # Runs the generation this instance was configured for, against a fresh,
216
- # isolated boot of `@domain_path`.
217
- #
218
- # @return [Array<Hash>] the generated step list, each a command, query, or
219
- # read-model step; a picker miss that produced no step is dropped
220
- def call
221
- # Real leftover data from ordinary use (bin/console, whatever) lives
222
- # under the example's data/ — a generator that boots against it
223
- # starts from state its own known_ids tracking doesn't know about.
224
- # IsolatedBoot resets that and rebinds persistence to Memory, since
225
- # a Postgres-bound domain's real store lives outside the copied
226
- # directory entirely and `rm_rf`ing data/ alone cannot reach it —
227
- # see isolated_boot.rb's own header.
228
- IsolatedBoot.call(@domain_path, adapter: @adapter) do |copy|
229
- runtime = Hecks.boot(copy)
230
- catalog = build_catalog(runtime)
231
- @verbs = catalog.values_at(:creating, :instance, :entity_commands, :queries, :entity_queries, :read_models)
232
- .flatten.map { |entry| entry[:verb] }.uniq
233
-
234
- steps = []
235
- if @prefix
236
- realize_prefix(runtime, catalog, @prefix, prefix_limit(@prefix), steps)
237
- restart_random(@seed)
238
- @favor = @own_favor
239
- end
240
- @step_count.times { steps << attempt_step(runtime, catalog) }
241
- steps.compact
242
- end
243
- end
244
-
245
- private
246
-
247
- # A prefix is the first `limit` attempts of another seed's generation,
248
- # re-run for real: its own nested prefix first (capped the same way it
249
- # was capped when that seed was generated), then that seed's own
250
- # `Random.new(seed)` and favor for the rest. Same inputs, same
251
- # catalog, same draws — the same steps, and the same known ids and
252
- # exercised verbs carried forward into this seed.
253
- #
254
- # The prefix is on top of this seed's own budget, not out of it. A
255
- # spliced seed still makes all `steps` attempts of its own after the
256
- # prefix; a prefix is capped at `steps` attempts, so a spliced
257
- # sequence is at most twice as long as an unspliced one. Taking the
258
- # prefix out of the budget (the first version of this) left a spliced
259
- # seed replaying state already seen with almost nothing left to
260
- # explore from it — measured: fewer distinct tuples than unguided.
261
- def realize_prefix(runtime, catalog, spec, limit, steps)
262
- return 0 unless limit.positive?
263
-
264
- inner = spec["prefix"]
265
- used = inner ? realize_prefix(runtime, catalog, inner, [prefix_limit(inner), limit].min, steps) : 0
266
- restart_random(Integer(spec.fetch("seed")))
267
- @favor = Array(spec["favor"])
268
- (limit - used).times { steps << attempt_step(runtime, catalog) }
269
- limit
270
- end
271
-
272
- # Both random streams start over together, so a prefix replay draws the
273
- # same query bindings the prefix seed's own generation did.
274
- def restart_random(seed)
275
- @random = Random.new(seed)
276
- @binding_random = Random.new(seed + QueryBinding::BINDING_SEED_OFFSET)
277
- end
278
-
279
- def prefix_limit(spec) = Integer(spec.fetch("steps")).clamp(0, @step_count)
280
-
281
- def attempt_step(runtime, catalog)
282
- index = @attempt
283
- @attempt += 1
284
- entry = pick(catalog)
285
- return nil unless entry
286
-
287
- @exercised << entry[:verb]
288
- @state_before = "-"
289
- step =
290
- if entry[:query] then build_query_step(runtime, entry)
291
- elsif entry[:model] then build_read_model_step(runtime, entry)
292
- else build_command_step(runtime, catalog, entry)
293
- end
294
- @coverage << [index, coverage_tuple(entry, step)]
295
- step
296
- end
297
-
298
- # `verb | kind | state before | mutation | outcome` — see
299
- # CoverageCampaign's header for why the unit is this and not the
300
- # verb. `state` is the addressed aggregate's lifecycle value (or
301
- # `exists`/`absent` for one without a lifecycle) read just before
302
- # dispatch; `mutation` names every adversarial mutation and its shape;
303
- # `outcome` is `ok` or the refusal class `safe_call` rescued.
304
- def coverage_tuple(entry, step)
305
- kind = %w[verb query dry_run].find { |key| step.key?(key) }
306
- mutation = Array(step["adversarial"]).map { |m| [m["mutation"], m["shape"]].compact.join(":") }.join("+")
307
- [entry[:verb], kind, @state_before, mutation.empty? ? "-" : mutation, @last_outcome].join(" | ")
308
- end
309
- end
310
- end
311
- end