hecks 1.0.2 → 1.1.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 (239) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +3 -3
  3. data/lib/hecks/adapters/driven/d1.rb +48 -28
  4. data/lib/hecks/adapters/driven/folder.rb +10 -3
  5. data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
  6. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +3 -5
  8. data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +2 -2
  10. data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
  11. data/lib/hecks/adapters/driven/local_storage.rb +120 -0
  12. data/lib/hecks/adapters/driven/memory.rb +48 -3
  13. data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
  14. data/lib/hecks/adapters/driven/postgres/outbox.rb +97 -0
  15. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +3 -2
  16. data/lib/hecks/adapters/driven/postgres.rb +25 -14
  17. data/lib/hecks/adapters/driven/prism.rb +11 -0
  18. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  19. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  20. data/lib/hecks/adapters/driven/sqlite/projection.rb +17 -1
  21. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  22. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  23. data/lib/hecks/adapters/driven.rb +4 -0
  24. data/lib/hecks/adapters.rb +4 -0
  25. data/lib/hecks/behaviors/dsl.rb +7 -0
  26. data/lib/hecks/behaviors/expectations.rb +32 -4
  27. data/lib/hecks/behaviors/rspec.rb +2 -0
  28. data/lib/hecks/behaviors/runner.rb +4 -0
  29. data/lib/hecks/bluebook/aggregate.rb +6 -5
  30. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  31. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  32. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  33. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  34. data/lib/hecks/bluebook/assembly/marks.rb +2 -1
  35. data/lib/hecks/bluebook/assembly.rb +1 -3
  36. data/lib/hecks/bluebook/attribute.rb +6 -0
  37. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  38. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  39. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  40. data/lib/hecks/bluebook/command.rb +6 -5
  41. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  42. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  43. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +19 -472
  44. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  45. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  46. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  47. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  48. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  49. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  50. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  51. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  52. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  53. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  54. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  55. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  56. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  57. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  58. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  59. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  60. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  61. data/lib/hecks/bluebook/dsl/read_model_builder.rb +11 -3
  62. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  63. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  64. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  65. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  66. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  67. data/lib/hecks/bluebook/entity.rb +5 -3
  68. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  69. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  70. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  71. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  72. data/lib/hecks/bluebook/expression/projection.json +12 -0
  73. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  74. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  75. data/lib/hecks/bluebook/expression.rb +1 -0
  76. data/lib/hecks/bluebook/hexagon.rb +10 -0
  77. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  78. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  79. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  80. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  81. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  82. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  83. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  84. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  85. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  86. data/lib/hecks/bluebook/model_check.rb +119 -82
  87. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  88. data/lib/hecks/bluebook/policy.rb +2 -1
  89. data/lib/hecks/bluebook/process_manager.rb +6 -0
  90. data/lib/hecks/bluebook/project_register.rb +45 -25
  91. data/lib/hecks/bluebook/query.rb +4 -0
  92. data/lib/hecks/bluebook/read_model.rb +2 -2
  93. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  94. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  95. data/lib/hecks/bluebook/translation.rb +3 -0
  96. data/lib/hecks/bluebook/value_object.rb +2 -2
  97. data/lib/hecks/codemod.rb +13 -0
  98. data/lib/hecks/doc/reference.rb +17 -11
  99. data/lib/hecks/facade/cli_runner.rb +2 -2
  100. data/lib/hecks/facade/command_request.rb +11 -11
  101. data/lib/hecks/facade/handle.rb +3 -3
  102. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  103. data/lib/hecks/facade.rb +4 -0
  104. data/lib/hecks/forms/app.rb +10 -6
  105. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  106. data/lib/hecks/forms/field_renderer.rb +14 -9
  107. data/lib/hecks/forms/field_shape.rb +7 -2
  108. data/lib/hecks/forms/html.rb +5 -2
  109. data/lib/hecks/forms/index_renderer.rb +1 -1
  110. data/lib/hecks/forms/page.rb +7 -3
  111. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  112. data/lib/hecks/forms/record_renderer.rb +22 -16
  113. data/lib/hecks/forms.rb +4 -0
  114. data/lib/hecks/fqn.rb +21 -9
  115. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  116. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  117. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  118. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  119. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  120. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  121. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  122. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  123. data/lib/hecks/fuzzing/properties.rb +41 -1065
  124. data/lib/hecks/fuzzing/replay.rb +60 -16
  125. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  126. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  127. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  128. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  129. data/lib/hecks/grammar/evolve.rb +34 -10
  130. data/lib/hecks/grammar/expression_operators.json +124 -0
  131. data/lib/hecks/grammar.rb +15 -5
  132. data/lib/hecks/ir.rb +9 -0
  133. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  134. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  135. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  136. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  138. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  140. data/lib/hecks/literal.rb +18 -8
  141. data/lib/hecks/naming.rb +6 -3
  142. data/lib/hecks/ports/access_control.rb +1 -1
  143. data/lib/hecks/ports/agent.rb +5 -3
  144. data/lib/hecks/ports/authentication.rb +3 -2
  145. data/lib/hecks/ports/authorization.rb +1 -1
  146. data/lib/hecks/ports/clock.rb +1 -1
  147. data/lib/hecks/ports/extraction.rb +5 -1
  148. data/lib/hecks/ports/identity_assignment.rb +1 -1
  149. data/lib/hecks/ports/identity_generation.rb +1 -1
  150. data/lib/hecks/ports/identity_resolution.rb +1 -1
  151. data/lib/hecks/ports/loading.rb +4 -1
  152. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  153. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  154. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  155. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  156. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  157. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  158. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  159. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +23 -13
  169. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  170. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  172. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  173. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  174. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  175. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  176. data/lib/hecks/ports/persistence.rb +7 -3
  177. data/lib/hecks/ports/projection.rb +13 -5
  178. data/lib/hecks/ports/query/in_memory.rb +8 -2
  179. data/lib/hecks/ports/query.rb +1 -3
  180. data/lib/hecks/ports.rb +2 -0
  181. data/lib/hecks/projections/diagrams.rb +11 -3
  182. data/lib/hecks/projections/ir.rb +2 -0
  183. data/lib/hecks/projections/model/deviations.rb +2 -1
  184. data/lib/hecks/projections/model.rb +15 -10
  185. data/lib/hecks/projections/vocabulary.rb +1 -3
  186. data/lib/hecks/projector/cli_projector.rb +49 -29
  187. data/lib/hecks/projector/docs_projector.rb +21 -12
  188. data/lib/hecks/projector/exporter.rb +10 -1
  189. data/lib/hecks/projector/narrate_projector.rb +83 -25
  190. data/lib/hecks/projector.rb +2 -2
  191. data/lib/hecks/query_ir.rb +60 -36
  192. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  193. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  194. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  195. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  196. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  197. data/lib/hecks/query_specification/common/options.rb +17 -5
  198. data/lib/hecks/query_specification/field_path.rb +4 -4
  199. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  200. data/lib/hecks/router/namespace_installer.rb +22 -10
  201. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  202. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  203. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  204. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  205. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  206. data/lib/hecks/runtime/dispatcher.rb +41 -7
  207. data/lib/hecks/runtime/entity_element.rb +25 -6
  208. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  209. data/lib/hecks/runtime/errors.rb +28 -2
  210. data/lib/hecks/runtime/instance.rb +9 -3
  211. data/lib/hecks/runtime/interpreting.rb +29 -2
  212. data/lib/hecks/runtime/loader.rb +21 -0
  213. data/lib/hecks/runtime/outbox.rb +365 -0
  214. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  215. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  216. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  217. data/lib/hecks/runtime/read_model_interpreter.rb +26 -3
  218. data/lib/hecks/runtime/reference_hop.rb +1 -1
  219. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  220. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  221. data/lib/hecks/runtime/registry/verification.rb +39 -2
  222. data/lib/hecks/runtime/registry.rb +21 -11
  223. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  224. data/lib/hecks/runtime/routing.rb +52 -39
  225. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  226. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  227. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  228. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  229. data/lib/hecks/runtime/value/coercion.rb +207 -48
  230. data/lib/hecks/runtime/value.rb +1 -1
  231. data/lib/hecks/runtime.rb +7 -2
  232. data/lib/hecks/storehouse.rb +15 -8
  233. data/lib/hecks/version.rb +1 -1
  234. data/lib/hecks/vocabulary.rb +1 -0
  235. data/lib/hecks.rb +17 -13
  236. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  237. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  238. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  239. metadata +14 -2
data/lib/hecks/fqn.rb CHANGED
@@ -6,22 +6,30 @@ module Hecks
6
6
  class Fqn
7
7
  class Invalid < ArgumentError; end
8
8
 
9
- attr_reader :realm, :domain, :version, :aggregate, :verb
9
+ attr_reader :realm, :domain, :version, :aggregate, :verb, :kind
10
10
 
11
11
  def self.command(realm:, domain:, aggregate:, command:, version: nil)
12
12
  new(realm: realm, domain: domain, version: version, aggregate: aggregate, verb: command, kind: :command)
13
13
  end
14
14
 
15
- def self.query(realm:, domain:, aggregate: nil, query:, version: nil)
15
+ def self.query(realm:, domain:, query:, aggregate: nil, version: nil)
16
16
  new(realm: realm, domain: domain, version: version, aggregate: aggregate, verb: query, kind: :query)
17
17
  end
18
18
 
19
+ # ONE ORDER-DEPENDENT PARSE PIPELINE: split -> shape-validate -> dispatch
20
+ # on segment count -> split domain/version -> classify kind -> cross-
21
+ # field validate -> construct. Each step consumes locals (segments, verb,
22
+ # kind) the step before it derived; splitting would mean threading all of
23
+ # them back out as parameters/returns between new methods, for no
24
+ # readability gain over reading the pipeline top to bottom once.
25
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
19
26
  def self.parse(text)
20
27
  head, separator, verb = text.to_s.rpartition(".")
21
28
  raise Invalid, "FQN must be Realm::Domain::Aggregate.verb — got #{text.inspect}" if separator.empty?
22
29
 
23
30
  segments = head.split("::", -1)
24
- raise Invalid, "FQN must be Realm::Domain::Aggregate.verb — got #{text.inspect}" unless [1, 2, 3].include?(segments.length) && segments.none?(&:empty?) && !verb.empty?
31
+ valid_fqn = [1, 2, 3].include?(segments.length) && segments.none?(&:empty?) && !verb.empty?
32
+ raise Invalid, "FQN must be Realm::Domain::Aggregate.verb — got #{text.inspect}" unless valid_fqn
25
33
 
26
34
  realm, domain_spec, aggregate = case segments.length
27
35
  when 3 then segments
@@ -48,7 +56,7 @@ module Hecks
48
56
  def self.command_name?(name) = /\A[A-Z][A-Za-z0-9]*\z/.match?(name.to_s)
49
57
  def self.query_name?(name) = /\A[a-z][a-z0-9_]*\z/.match?(name.to_s)
50
58
 
51
- def initialize(realm:, domain:, version: nil, aggregate:, verb:, kind:)
59
+ def initialize(realm:, domain:, aggregate:, verb:, kind:, version: nil)
52
60
  @realm = realm && segment(realm, "realm")
53
61
  @domain = segment(domain, "domain")
54
62
  @version = version && segment(version, "version")
@@ -62,7 +70,6 @@ module Hecks
62
70
 
63
71
  def command? = @kind == :command
64
72
  def query? = @kind == :query
65
- def kind = @kind
66
73
 
67
74
  def to_s
68
75
  domain = @version ? "#{@domain}@#{@version}" : @domain
@@ -77,16 +84,21 @@ module Hecks
77
84
 
78
85
  def self.split_domain(value)
79
86
  domain, version, extra = value.to_s.split("@", 3)
80
- raise Invalid,
81
- "FQN domain version is malformed: #{value.inspect}" if domain.empty? || extra || (value.include?("@") && version.to_s.empty?)
87
+ if domain.empty? || extra || (value.include?("@") && version.to_s.empty?)
88
+ raise Invalid,
89
+ "FQN domain version is malformed: #{value.inspect}"
90
+ end
82
91
 
83
92
  [domain, version]
84
93
  end
94
+ private_class_method :split_domain
85
95
 
86
96
  def segment(value, label)
87
97
  text = value.to_s
88
- raise Invalid,
89
- "FQN #{label} cannot be empty or contain a separator" if text.empty? || text.include?("::") || text.include?(".") || text.include?("@")
98
+ if text.empty? || text.include?("::") || text.include?(".") || text.include?("@")
99
+ raise Invalid,
100
+ "FQN #{label} cannot be empty or contain a separator"
101
+ end
90
102
 
91
103
  text
92
104
  end
@@ -101,7 +101,7 @@ module Hecks
101
101
  # actually bound to.
102
102
  ARRAY_ELEMENT_TYPE = { "arr_num" => :numeric, "arr_str" => :string }.freeze
103
103
 
104
- BLOCK_PARAM = "el"
104
+ BLOCK_PARAM = "el".freeze
105
105
 
106
106
  MAX_DEPTH = 3
107
107
 
@@ -217,7 +217,9 @@ module Hecks
217
217
  when :string then string_productions(depth)
218
218
  when :boolean then boolean_productions(depth)
219
219
  when :array then array_productions(depth)
220
- when :nil_type then [] # no recursive producer of nil in this grammar — Find's "not found" is a runtime OUTCOME, not a distinct construct to render as source text
220
+ # no recursive producer of nil in this grammar — Find's "not found" is a
221
+ # runtime OUTCOME, not a distinct construct to render as source text
222
+ when :nil_type then []
221
223
  else raise ArgumentError, "no production rule for type #{type.inspect}"
222
224
  end
223
225
  end
@@ -291,8 +293,12 @@ module Hecks
291
293
  def resolver_boolean_leaves(depth)
292
294
  leaves(:boolean) +
293
295
  resolver_numeric_leaves(depth).flat_map { |n| ["#{n}.positive?", "#{n}.negative?", "#{n}.zero?"] } +
294
- bounded(:string, depth).flat_map { |s| ["#{s}.present?", "#{s}.blank?", "#{s}.match?(/a/)", "#{s}.start_with?(\"a\")", "#{s}.end_with?(\"a\")"] } +
295
- (sample(numeric_array_productions(depth)) + sample(string_array_productions(depth)) + bounded(:array, depth)).map { |x| "#{x}.empty?" } +
296
+ bounded(:string, depth).flat_map do |s|
297
+ ["#{s}.present?", "#{s}.blank?", "#{s}.match?(/a/)", "#{s}.start_with?(\"a\")", "#{s}.end_with?(\"a\")"]
298
+ end +
299
+ (sample(numeric_array_productions(depth)) + sample(string_array_productions(depth)) + bounded(:array, depth)).map do |x|
300
+ "#{x}.empty?"
301
+ end +
296
302
  sample(block_predicate_productions(depth))
297
303
  end
298
304
 
@@ -386,6 +392,15 @@ module Hecks
386
392
  # typed at its own top level (`Evaluator.truthy?`), so this is also
387
393
  # the set `all_predicates` (below) draws its top-level cases from
388
394
  # directly.
395
+ # One declarative table of boolean productions, not several
396
+ # unrelated things bolted together — each `+`-joined term is a
397
+ # distinct grammar rule, and several carry their own comment
398
+ # explaining a specific inclusion/exclusion choice (the
399
+ # `resolver_numeric_leaves`-not-`num` note above, `arr_num`'s non-
400
+ # empty twin, etc). Extracting terms into separate methods would
401
+ # need `sub`/`num`/`str` threaded into each as parameters and
402
+ # would separate every rule from the comment justifying it.
403
+ # rubocop:disable-next Metrics/AbcSize
389
404
  def boolean_productions(depth)
390
405
  sub = bounded(:boolean, depth - 1)
391
406
  num = bounded(:numeric, depth - 1)
@@ -404,8 +419,11 @@ module Hecks
404
419
  # documents (`"0 + 0.positive?"` would parse as `0 + (0
405
420
  # .positive?)`, not `(0 + 0).positive?`).
406
421
  resolver_numeric_leaves(depth - 1).flat_map { |n| ["#{n}.positive?", "#{n}.negative?", "#{n}.zero?"] } +
407
- (str + sample(numeric_array_productions(depth - 1)) + sample(string_array_productions(depth - 1)) + bounded(:array, depth - 1))
408
- .map { |x| "#{x}.empty?" } +
422
+ (str + sample(numeric_array_productions(depth - 1)) + sample(string_array_productions(depth - 1)) + bounded(:array,
423
+ depth - 1))
424
+ .map do |x|
425
+ "#{x}.empty?"
426
+ end +
409
427
  str.map { |s| "#{s}.match?(/a/)" } +
410
428
  str.flat_map { |s| ["#{s}.present?", "#{s}.blank?"] } +
411
429
  str.flat_map { |s| ["#{s}.start_with?(\"a\")", "#{s}.end_with?(\"a\")"] } +
@@ -461,8 +479,12 @@ module Hecks
461
479
  def include_productions(depth)
462
480
  str = bounded(:string, depth)
463
481
  pairs(str).map { |haystack, needle| "#{haystack}.include?(#{needle})" } +
464
- sample(numeric_array_productions(depth)).product(bounded(:numeric, depth)).map { |arr, needle| "#{arr}.include?(#{needle})" } +
465
- sample(string_array_productions(depth)).product(bounded(:string, depth)).map { |arr, needle| "#{arr}.include?(#{needle})" }
482
+ sample(numeric_array_productions(depth)).product(bounded(:numeric, depth)).map do |arr, needle|
483
+ "#{arr}.include?(#{needle})"
484
+ end +
485
+ sample(string_array_productions(depth)).product(bounded(:string, depth)).map do |arr, needle|
486
+ "#{arr}.include?(#{needle})"
487
+ end
466
488
  end
467
489
 
468
490
  # SAMPLED, NOT A FULL CROSS PRODUCT — a full `list.product(list)`
@@ -57,7 +57,6 @@ module Hecks
57
57
  when :numeral_string then random.rand(1..1000).to_s
58
58
  when :array_for_scalar then [random.rand(1..10), random.rand(1..10)]
59
59
  when :boolean_for_string then random.rand(2).zero?
60
- else nil
61
60
  end
62
61
  end
63
62
 
@@ -103,7 +103,15 @@ module Hecks
103
103
  FileUtils.mkdir_p(target)
104
104
  else
105
105
  FileUtils.mkdir_p(File.dirname(target))
106
- FileUtils.cp(path, target)
106
+ begin
107
+ FileUtils.cp(path, target)
108
+ rescue Errno::ENOENT
109
+ # A transient sibling (an adapter's atomic-write `.tmp.<pid>`
110
+ # file, caught mid-rename by the glob above) can vanish
111
+ # between listing and copy — under parallel_r-spec two
112
+ # workers share the real example tree. A file that no longer
113
+ # exists was never part of the state this copy needs.
114
+ end
107
115
  end
108
116
  end
109
117
  end
@@ -160,8 +168,8 @@ module Hecks
160
168
  # process-unique schema name (`SecureRandom.hex` is already
161
169
  # `require`d here for exactly that day) — flagged, not solved,
162
170
  # since nothing today calls this from more than one thread.
163
- FUZZ_POSTGRES_DATABASE = "hecks_fuzz"
164
- FUZZ_POSTGRES_SCHEMA = "hecks_fuzz"
171
+ FUZZ_POSTGRES_DATABASE = "hecks_fuzz".freeze
172
+ FUZZ_POSTGRES_SCHEMA = "hecks_fuzz".freeze
165
173
 
166
174
  def rebind_to_postgres!(copy)
167
175
  require "pg"
@@ -182,7 +190,7 @@ module Hecks
182
190
  next if names.empty?
183
191
 
184
192
  world_path = File.join(File.dirname(hecksagon_path), "hecks_fuzz_postgres.world")
185
- File.write(world_path, names.map { |name|
193
+ File.write(world_path, names.map do |name|
186
194
  <<~WORLD
187
195
  Hecks.world "#{name}" do
188
196
  persisted_by("Postgres") do
@@ -191,7 +199,7 @@ module Hecks
191
199
  end
192
200
  end
193
201
  WORLD
194
- }.join("\n"))
202
+ end.join("\n"))
195
203
  end
196
204
 
197
205
  # Any PRE-EXISTING `.world` this copy shipped with (a real
@@ -277,7 +285,7 @@ module Hecks
277
285
  # never wires is simply unread, not broken.
278
286
  def rewrite_bindings!(copy, adapter_name)
279
287
  Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |path|
280
- lines = File.readlines(path).reject { |line| line.match?(/\bprojected_by\s*\(?\s*"/) }
288
+ lines = File.readlines(path).grep_v(/\bprojected_by\s*\(?\s*"/)
281
289
  File.write(path, lines.join.gsub(/persisted_by\s*\(?\s*"[^"]+"\s*\)?/, "persisted_by(\"#{adapter_name}\")"))
282
290
  end
283
291
  end
@@ -0,0 +1,264 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ module Properties
4
+ # Dispatch-binding and mutation-recomputation properties: a saga/
5
+ # policy dispatch is bound to the value its own with_spec names, and a
6
+ # command's append/remove/multiply/clamp mutations land on the same
7
+ # after-state an independent recomputation of the SAME rule produces.
8
+
9
+ # Holds dispatch_binding_fidelity and mutations_match_recompute, plus
10
+ # the independently-written re-derivations (#resolve_dispatch_binding,
11
+ # #resolve_trigger_binding, #recompute_append/#remove/#multiply/#clamp)
12
+ # each checks the real dispatch/mutation against.
13
+ module DispatchAndMutations
14
+ # Closes Handler#dispatches and — the same shape, one construct
15
+ # over — Policy#with_spec (Dispatch#command_name/Dispatch#with_spec
16
+ # are not claimable feature strings at all — see FEATURE_COVERAGE's
17
+ # own comment on this entry). history[:saga_dispatches]/[:policy_dispatches]
18
+ # (Registry#saga_dispatch_log/#policy_dispatch_log — additive,
19
+ # Ruby-only, NEVER touching saga_log/reaction_log, the byte-for-
20
+ # byte shape spec/rust_conformance_spec.rb holds Rust to) each
21
+ # carry the RAW inputs a dispatch's own args were resolved from,
22
+ # captured live at the moment the resolution actually ran — a
23
+ # saga's own memory keeps changing across a run, so re-deriving
24
+ # from history[:saga_instances]'s FINAL memory (the only other
25
+ # place it would be visible) would grade the wrong moment,
26
+ # lifecycle_guard_and_given_violations_are_refused's own false
27
+ # positive one item earlier, in a different shape.
28
+ #
29
+ # #resolve_dispatch_binding/#resolve_trigger_binding are SEPARATE,
30
+ # independently-written re-derivations of SagaInterpreter#
31
+ # dispatch_args/PolicyInterpreter#trigger_args's own resolution —
32
+ # never calling either method again, which would only ever agree
33
+ # with itself. Exactly the class of bug this closes: "a wrong
34
+ # argument binding on a fan-out dispatch that produces a perfectly
35
+ # normal-looking log entry (`delivered: true`) and would only ever
36
+ # surface as a downstream assertion failure, if it surfaces at
37
+ # all" — PR #325's own defect class, one level over.
38
+ #
39
+ # Real targets: Settlement (mixed literal/correlation-head/event-
40
+ # payload/memory-fallback bindings across three legs, plus a
41
+ # compensation leg that deliberately omits `reference:`, a field
42
+ # the forward Credit leg carries), ExternalSettlement, Onboarding
43
+ # (no compensation leg, by design — nothing to check there beyond
44
+ # the forward leg's own event-payload binding).
45
+ def dispatch_binding_fidelity(history)
46
+ saga_offenders = history.fetch(:saga_dispatches, []).filter_map do |entry|
47
+ expected = resolve_dispatch_binding(entry)
48
+ next if expected == entry[:args]
49
+
50
+ "#{entry[:process_manager]}##{entry[:instance]} dispatching #{entry[:dispatch]} on #{entry[:on]} — " \
51
+ "bound #{entry[:args].inspect}, but independently re-deriving with_spec's own resolution gives " \
52
+ "#{expected.inspect}"
53
+ end
54
+
55
+ policy_offenders = history.fetch(:policy_dispatches, []).filter_map do |entry|
56
+ expected = resolve_trigger_binding(entry)
57
+ next if expected == entry[:args]
58
+
59
+ "#{entry[:policy]} on #{entry[:on]} — bound #{entry[:args].inspect}, but independently re-deriving " \
60
+ "with_spec's own resolution gives #{expected.inspect}"
61
+ end
62
+
63
+ offenders = saga_offenders + policy_offenders
64
+ offenders.empty? || offenders.join("; ")
65
+ end
66
+
67
+ # SagaInterpreter#dispatch_args's own 4-branch resolution,
68
+ # reproduced independently: a literal, the correlation key itself,
69
+ # the CURRENT triggering event's own payload, or — the fallback —
70
+ # the saga's own carried memory (seeded from the STARTING event's
71
+ # payload, at begin_saga).
72
+ def resolve_dispatch_binding(entry)
73
+ entry[:with_spec].to_h do |key, value|
74
+ resolved = if !value.is_a?(Symbol) then value
75
+ elsif value == entry[:correlation_head] then entry[:instance]
76
+ elsif entry[:event_payload].key?(value) then entry[:event_payload][value]
77
+ else entry[:memory][value]
78
+ end
79
+ [key.to_sym, Runtime::Value.materialize(resolved)]
80
+ end
81
+ end
82
+
83
+ # PolicyInterpreter#trigger_args's own 2-branch resolution — a
84
+ # policy holds no correlation and no memory, so `payload` (the
85
+ # triggering event's own payload, already merged with a fan-out
86
+ # row's id when there is one) is the WHOLE source.
87
+ def resolve_trigger_binding(entry)
88
+ entry[:with_spec].to_h do |key, value|
89
+ resolved = value.is_a?(Symbol) ? entry[:payload][value] : value
90
+ [key.to_sym, Runtime::Value.materialize(resolved)]
91
+ end
92
+ end
93
+
94
+ # Closes Command#mutations — the last of the five, and the
95
+ # largest: no real corpus entity anywhere uses `append`/`remove`/
96
+ # `multiply`/`clamp` (every real aggregate-owned mutation is
97
+ # aggregate-scoped — Account.Credit's :ledger, LogVisit's
98
+ # :visits); the only entity-owned use of these four ops in the
99
+ # whole repository is spec/fixtures/entity_list_mutations, now a
100
+ # real, bootable, Memory-default domain (no .hecksagon needed at
101
+ # all — a domain with none boots every aggregate against Memory
102
+ # by construction, confirmed live) rather than the raw-Kernel.
103
+ # load-only fixture it was.
104
+ #
105
+ # `history[:mutation_traces]` (Replay's own bounded, additive
106
+ # extension — see #build_mutation_trace's own comment) carries a
107
+ # per-step before/after snapshot of the ENTITY ELEMENT an
108
+ # entity-dispatched command's own mutations acted on, materialized
109
+ # to plain data, plus the step's own raw args — the delta
110
+ # `aggregation_matches_recompute` never had to ask for, because
111
+ # count/median are pure functions of FINAL state and a mutation
112
+ # is not (the same "captured live, not re-derived from final
113
+ # state" lesson item 8's own saga_dispatch_log already learned).
114
+ #
115
+ # #recompute_append/#recompute_remove/#recompute_multiply/
116
+ # #recompute_clamp are SEPARATE, independently-written
117
+ # reproductions of MutationApplier#appended/#removed and
118
+ # CommandRules::Arithmetic#multiply/#clamp — never calling either
119
+ # again, which would only ever agree with itself. `:set`/
120
+ # `:increment`/`:decrement` are out of scope on purpose (the four
121
+ # "vendored, not yet upstream" ops this item exists for); a
122
+ # command mixing them with a recomputable op still gets the
123
+ # recomputable one checked.
124
+ #
125
+ # `:unrecomputable` (never compared, never a finding) covers the
126
+ # generator's own deliberate arg-malforming (`StepBuilder#malform`)
127
+ # landing a non-Numeric amount/non-2-element bounds where
128
+ # multiply/clamp need one — the SAME shape `guard_check`'s own
129
+ # AbsentArgument false positive taught: a step whose raw material
130
+ # doesn't fit the op's own contract is inconclusive, not a claimed
131
+ # mismatch.
132
+ RECOMPUTABLE_MUTATION_OPS = %i[append remove multiply clamp].freeze
133
+
134
+ def mutations_match_recompute(history)
135
+ bluebooks = history.fetch(:bluebooks)
136
+
137
+ offenders = history.fetch(:mutation_traces, []).flat_map do |entry|
138
+ next [] unless entry[:after]
139
+
140
+ command = command_for_verb(bluebooks, entry[:verb])
141
+ next [] unless command
142
+
143
+ command.mutations.select { |m| RECOMPUTABLE_MUTATION_OPS.include?(m.op) }.filter_map do |mutation|
144
+ expected = recompute_mutation(mutation, entry[:before][mutation.target], entry[:args], entry[:before])
145
+ next if expected == :unrecomputable
146
+
147
+ actual = entry[:after][mutation.target]
148
+ next if symbolize_deep(expected) == symbolize_deep(actual)
149
+
150
+ "#{entry[:verb]} — #{mutation.op} on #{mutation.target} — recomputing independently gives " \
151
+ "#{expected.inspect}, but the real dispatch left #{actual.inspect}"
152
+ end
153
+ end
154
+
155
+ offenders.empty? || offenders.join("; ")
156
+ end
157
+
158
+ def recompute_mutation(mutation, current, args, before_scope)
159
+ case mutation.op
160
+ when :append then recompute_append(current, mutation.source, before_scope, args)
161
+ when :remove then recompute_remove(current, mutation.source, args)
162
+ when :multiply then recompute_multiply(current, resolve_mutation_source(mutation.source, args))
163
+ when :clamp then recompute_clamp(current, mutation.source)
164
+ end
165
+ end
166
+
167
+ # `MutationApplier#appended`'s own value-object branch (never the
168
+ # entity_element branch — see #build_mutation_trace's own comment
169
+ # on why an entity-dispatched command's own mutations never reach
170
+ # it), reproduced: the field map resolved the SAME two-tier way
171
+ # (`MutationApplier#resolve_append_source` — a caller-supplied
172
+ # arg, or the entity's own current field), then appended.
173
+ def recompute_append(current, source_map, before_scope, args)
174
+ fields = source_map.transform_values { |source| resolve_mutation_append_field(source, before_scope, args) }
175
+ Array(current) + [symbolize_deep(fields)]
176
+ end
177
+
178
+ def resolve_mutation_append_field(source, before_scope, args)
179
+ return source unless source.is_a?(Symbol)
180
+ return args[source] if args.key?(source)
181
+
182
+ before_scope[source]
183
+ end
184
+
185
+ # `MutationApplier#removed`'s own value-equality match, reproduced.
186
+ def recompute_remove(current, source, args)
187
+ target = symbolize_deep(resolve_mutation_source(source, args))
188
+ Array(current).reject { |element| symbolize_deep(element) == target }
189
+ end
190
+
191
+ # `CommandRules::Arithmetic#multiply`'s own two branches,
192
+ # reproduced on plain materialized data instead of a real Value:
193
+ # a single-numeric-field Hash (the VO-typed case — ListCount, one
194
+ # Integer field) scales that field ; a bare Numeric scales itself.
195
+ # `current ||= 0` — the SAME phantom-field fallback #multiply
196
+ # itself already gives (unaffected by this session's #clamp fix,
197
+ # since #multiply never needed one).
198
+ def recompute_multiply(current, amount)
199
+ return :unrecomputable unless amount.is_a?(Numeric)
200
+
201
+ current ||= 0
202
+ if current.is_a?(Hash)
203
+ field = current.keys.find { |f| current[f].is_a?(Numeric) }
204
+ return :unrecomputable unless field
205
+
206
+ current.merge(field => current[field] * amount)
207
+ elsif current.is_a?(Numeric)
208
+ current * amount
209
+ else
210
+ :unrecomputable
211
+ end
212
+ end
213
+
214
+ # `CommandRules::Arithmetic#clamp`'s own two branches, reproduced
215
+ # the same way #recompute_multiply is — including THIS SESSION'S
216
+ # OWN `current ||= 0` fix (command_rules/arithmetic.rb), the one
217
+ # arithmetic op that didn't have it until now. `mutation.source`
218
+ # is always a literal `[min, max]`, never an argument reference
219
+ # (MutationApplier's own comment on why `resolve_source` is
220
+ # skipped for clamp) — so nothing here reads `args` for it at all.
221
+ def recompute_clamp(current, bounds)
222
+ return :unrecomputable unless bounds.is_a?(Array) && bounds.size == 2
223
+
224
+ min, max = bounds
225
+ current ||= 0
226
+ if current.is_a?(Hash)
227
+ field = current.keys.find { |f| current[f].is_a?(Numeric) }
228
+ return :unrecomputable unless field
229
+
230
+ current.merge(field => current[field].clamp(min, max))
231
+ elsif current.is_a?(Numeric)
232
+ current.clamp(min, max)
233
+ else
234
+ :unrecomputable
235
+ end
236
+ end
237
+
238
+ # `CommandRules::Arithmetic#resolve_source`, reproduced: a
239
+ # mutation's source is either the NAME OF AN ARGUMENT or a
240
+ # LITERAL, told apart by type.
241
+ def resolve_mutation_source(source, args)
242
+ source.is_a?(Symbol) ? args[source] : source
243
+ end
244
+
245
+ # A generated step's own `args` arrive with STRING keys on every
246
+ # nested Hash (the wire/JSON shape `spec/corpus/*.json` already
247
+ # uses) while `history[:mutation_traces]`' own materialized
248
+ # before/after state carries SYMBOL keys throughout (Runtime::
249
+ # Value.materialize's own convention) — two hashes holding the
250
+ # identical fact compare UNEQUAL by Ruby's own `Hash#==` unless
251
+ # both sides are normalized the same way first. Recursive, since
252
+ # an appended/removed element can itself nest a value object
253
+ # (RemoveTag's own `Tag` argument, `{"key"=>..., "value"=>...}`).
254
+ def symbolize_deep(value)
255
+ case value
256
+ when Hash then value.to_h { |key, val| [key.to_sym, symbolize_deep(val)] }
257
+ when Array then value.map { |val| symbolize_deep(val) }
258
+ else value
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end