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
@@ -9,6 +9,7 @@ require_relative "../../query_specification/common/order_by"
9
9
  require_relative "../../query_specification/field_path"
10
10
  require_relative "../../runtime/errors"
11
11
  require_relative "../../runtime/event"
12
+ require_relative "postgres/outbox"
12
13
  require_relative "../../runtime/instance"
13
14
 
14
15
  module Hecks
@@ -44,6 +45,7 @@ module Hecks
44
45
  include SqlQueryBuilder
45
46
  include SchemaBuilder
46
47
  include Codec
48
+ include PostgresOutbox
47
49
 
48
50
  SQL_TYPES = { "Integer" => "bigint", "Float" => "double precision" }.freeze
49
51
 
@@ -84,9 +86,9 @@ module Hecks
84
86
  # boot after the first, not news.
85
87
  connection.exec("SET client_min_messages = warning")
86
88
  connection
87
- rescue PG::Error => error
89
+ rescue PG::Error => e
88
90
  raise Runtime::WiringError,
89
- "cannot bind Postgres at #{declared} for #{name}: #{error.message.strip}"
91
+ "cannot bind Postgres at #{declared} for #{name}: #{e.message.strip}"
90
92
  end
91
93
 
92
94
  def initialize(aggregate:, settings: {}, root: nil)
@@ -110,6 +112,7 @@ module Hecks
110
112
  create_entry_table!
111
113
  create_event_table!
112
114
  create_saga_table!
115
+ create_outbox_table!
113
116
  end
114
117
 
115
118
  def table = @aggregate.storage_name
@@ -128,7 +131,10 @@ module Hecks
128
131
  order_sql = "ORDER BY id"
129
132
  if order_by
130
133
  name = order_by.to_s.split(".").first
131
- raise Runtime::WiringError, "#{@aggregate.name} has no attribute #{order_by.inspect} to order by" unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
134
+ unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
135
+ raise Runtime::WiringError,
136
+ "#{@aggregate.name} has no attribute #{order_by.inspect} to order by"
137
+ end
132
138
 
133
139
  spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
134
140
  order_sql = "ORDER BY #{order_clause(spec, nil)}"
@@ -169,6 +175,8 @@ module Hecks
169
175
  # zero rows back means the conflict branch's WHERE excluded the row
170
176
  # entirely — the version had already moved — so `nil` is returned
171
177
  # for the caller (`AppendOnly#save`) to treat as "stale, no-op".
178
+ # rubocop:disable Metrics/AbcSize -- the CAS/plain upsert split is one
179
+ # protocol; splitting it would hide the version handshake.
172
180
  def project(entry, expected_version: nil)
173
181
  return @db.exec_params("DELETE FROM #{quoted_table} WHERE id = $1", [entry.id]) if entry.delete?
174
182
 
@@ -193,6 +201,7 @@ module Hecks
193
201
  instance.version = result[0]["hecks_version"].to_i
194
202
  instance
195
203
  end
204
+ # rubocop:enable Metrics/AbcSize
196
205
 
197
206
  def entries
198
207
  @db.exec("SELECT aggregate_id, operation, state, mirrors FROM #{quoted_entry_table} ORDER BY sequence").map do |row|
@@ -221,18 +230,15 @@ module Hecks
221
230
  # transaction lives here, the one caller that runs both together.
222
231
  def save(instance)
223
232
  entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
224
- @db.transaction { append(entry); project(entry) }
233
+ transaction do
234
+ append(entry)
235
+ project(entry)
236
+ end
225
237
  end
226
238
 
227
- # Classification, journal append and snapshot replacement are one
228
- # Postgres transaction. A row lock cannot serialize two first writers —
229
- # there is no row to lock yet — so a transaction-scoped advisory lock on
230
- # schema/table + aggregate id owns that missing-row race as well. Once a
231
- # writer acquires it, the preceding writer has committed and status can
232
- # be read from the materialized table without a runtime-side find.
233
239
  def atomic_put(entry, insert_only: false)
234
240
  status = nil
235
- @db.transaction do
241
+ transaction do
236
242
  @db.exec_params(
237
243
  "SELECT pg_advisory_xact_lock(" \
238
244
  "hashtext(current_schema() || ':' || $1), hashtext($2))",
@@ -255,7 +261,10 @@ module Hecks
255
261
 
256
262
  def delete(id)
257
263
  entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
258
- @db.transaction { append(entry); project(entry) }
264
+ transaction do
265
+ append(entry)
266
+ project(entry)
267
+ end
259
268
  true
260
269
  end
261
270
 
@@ -288,7 +297,8 @@ module Hecks
288
297
  "ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \
289
298
  "SET state = EXCLUDED.state, memory = EXCLUDED.memory, " \
290
299
  "completed_compensations = EXCLUDED.completed_compensations, updated_at = now()",
291
- [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
300
+ [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
301
+ JSON.generate(completed_compensations)]
292
302
  )
293
303
  end
294
304
 
@@ -303,7 +313,8 @@ module Hecks
303
313
  return enum_for(:each_saga) unless block_given?
304
314
 
305
315
  @db.exec_params(
306
- "SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = $1",
316
+ "SELECT process_manager, correlation, state, memory, completed_compensations " \
317
+ "FROM hecks_saga_instances WHERE domain = $1",
307
318
  [@domain]
308
319
  ).each do |row|
309
320
  yield row["process_manager"], row["correlation"], row["state"],
@@ -5,7 +5,18 @@ module Hecks
5
5
  module Adapters
6
6
  class NotExtractable < StandardError; end
7
7
 
8
+ # Extracts a `given`/`ensures`/invariant block's own source text back out
9
+ # of the `.rb` file it was defined in (via `block.source_location`),
10
+ # parses it with the `prism` gem, and canonicalises it — how a rule's
11
+ # predicate becomes readable, comparable text (era diffing, docs) rather
12
+ # than an opaque compiled Proc. Per-process `TREES` cache keyed by file
13
+ # path; `forget`/`forget_all` exist for a caller that reloads an edited
14
+ # file in-process (see their own comment).
8
15
  module Prism
16
+ # NOT frozen — a real cache, keyed by file path and mutated by
17
+ # #tree_for below (`TREES[file] ||= ...`) and #forget/#forget_all.
18
+ # False positive for Style/MutableConstant.
19
+ # rubocop:disable-next Style/MutableConstant
9
20
  TREES = {}
10
21
 
11
22
  module_function
@@ -34,24 +34,11 @@ module Hecks
34
34
  }.freeze
35
35
 
36
36
  def query(declared, args = {}, context: {})
37
- sql = +"SELECT #{select_list} FROM #{from_relation}"
38
- clauses = []
37
+ sql = "SELECT #{select_list} FROM #{from_relation}"
39
38
  binds = []
40
- declared.wheres.each do |clause|
41
- value = query_value(clause.value, args)
42
- expression = query_expression(clause.field, value: value)
43
- if (null_predicate = QuerySpecification::Common::NullPolicy.sql_predicate(expression, clause.op, value))
44
- clauses << null_predicate.first
45
- next
46
- end
47
- clauses << where_clause(clause.op.to_s, expression, value, binds, field: clause.field)
48
- end
39
+ clauses = where_clauses(declared, args, binds)
49
40
  sql << " WHERE #{clauses.join(' AND ')}" unless clauses.empty?
50
- sql << if declared.order_by
51
- " ORDER BY #{order_clause(declared.order_by, declared.null_semantics)}"
52
- else
53
- " ORDER BY id"
54
- end
41
+ sql << order_by_sql(declared)
55
42
  sql << " LIMIT #{placeholder(binds, query_value(declared.limit.value, args).to_i)}" if declared.limit
56
43
  # An offset with no declared limit — SQLite refuses a bare OFFSET
57
44
  # outright (LIMIT -1 is its unbounded idiom) while Postgres accepts
@@ -65,10 +52,34 @@ module Hecks
65
52
 
66
53
  private
67
54
 
68
- def where_clause(op, expression, value, binds, field: nil)
69
- case op
55
+ # Every declared `where` clause, compiled in declaration order — a
56
+ # null-policy predicate short-circuits the ordinary comparator path
57
+ # per clause, same as it did inline. `binds` is populated in place
58
+ # (the same array `query` goes on to push LIMIT/OFFSET placeholders
59
+ # into afterward), so pulling this loop out changes nothing about
60
+ # bind-parameter order.
61
+ def where_clauses(declared, args, binds)
62
+ declared.wheres.each_with_object([]) do |clause, clauses|
63
+ value = query_value(clause.value, args)
64
+ expression = query_expression(clause.field, value: value)
65
+ if (null_predicate = QuerySpecification::Common::NullPolicy.sql_predicate(expression, clause.op, value))
66
+ clauses << null_predicate.first
67
+ next
68
+ end
69
+ clauses << where_clause(clause.op.to_s, expression, value, binds, field: clause.field)
70
+ end
71
+ end
72
+
73
+ def order_by_sql(declared)
74
+ return " ORDER BY #{order_clause(declared.order_by, declared.null_semantics)}" if declared.order_by
75
+
76
+ " ORDER BY id"
77
+ end
78
+
79
+ def where_clause(oper, expression, value, binds, field: nil)
80
+ case oper
70
81
  when "eq", "ne", "gt", "gte", "lt", "lte"
71
- "#{comparable_expression(expression, value)} #{COMPARATORS.fetch(op)} #{placeholder(binds, value)}"
82
+ "#{comparable_expression(expression, value)} #{COMPARATORS.fetch(oper)} #{placeholder(binds, value)}"
72
83
  when "contains"
73
84
  member = field && list_member(field)
74
85
  if member
@@ -97,7 +108,7 @@ module Hecks
97
108
  # which Postgres overrides to cast numerics.
98
109
  "CAST(#{expression} AS TEXT) IN (#{members.map { |member| placeholder(binds, member) }.join(', ')})"
99
110
  else
100
- raise ArgumentError, "#{dialect_name} query adapter does not support #{op.inspect}"
111
+ raise ArgumentError, "#{dialect_name} query adapter does not support #{oper.inspect}"
101
112
  end
102
113
  end
103
114
 
@@ -137,6 +148,16 @@ module Hecks
137
148
  # path that can never match. Measured, not assumed: a `where` on a
138
149
  # has_one/belongs_to/reference_to field returned zero rows against
139
150
  # real data until that exclusion was removed.
151
+ # `member` resolves through two ORDERED fallback tiers — the bound
152
+ # value's own numeric field first, then the declared value object's
153
+ # numeric-or-sole attribute — each documented above as fixing a
154
+ # real, measured bug (a reference field matching nothing, a
155
+ # single-attribute VO silently falling to the wrong convention).
156
+ # Splitting the tiers apart would separate two writes to the same
157
+ # `member` local across method boundaries, hiding the fallback
158
+ # order that makes them correct together.
159
+ # rubocop:disable-next Metrics/CyclomaticComplexity
160
+ # rubocop:disable-next Metrics/PerceivedComplexity
140
161
  def query_expression(field, value: nil)
141
162
  name, *path = field.to_s.split(".")
142
163
  attribute = @aggregate.attribute(name)
@@ -147,6 +168,10 @@ module Hecks
147
168
  member = if path.empty? && value
148
169
  hash = value.is_a?(Runtime::Value) ? value.to_h : value
149
170
  numeric = hash.is_a?(Hash) && hash.find { |_key, item| item.is_a?(Numeric) }
171
+ # NOT &.-able: `numeric` can be `false` (hash.is_a?(Hash) came
172
+ # back false) as well as nil (.find came back empty) — `&.`
173
+ # only guards nil, so `false.first` raises. False positive.
174
+ # rubocop:disable-next Style/SafeNavigation
150
175
  numeric ? numeric.first : nil
151
176
  end
152
177
  member ||= if path.empty? && attribute && value_object?(attribute)
@@ -13,7 +13,9 @@ module Hecks
13
13
  { name: attribute.name, attribute: attribute, sql_type: sql_type(attribute) }
14
14
  end
15
15
  lifecycle = @aggregate.lifecycle
16
- fields << { name: lifecycle.field, attribute: nil, sql_type: "TEXT" } if lifecycle && !fields.any? { |field| field[:name] == lifecycle.field }
16
+ fields << { name: lifecycle.field, attribute: nil, sql_type: "TEXT" } if lifecycle && fields.none? do |field|
17
+ field[:name] == lifecycle.field
18
+ end
17
19
  # `projects` FIELDS (S12, ADR 0025) ARE A LOCAL COLUMN TOO —
18
20
  # `CommandInterpreter#seed_projected_fields`/`RebuildSweep`
19
21
  # both write one straight into `Instance#state` the same as
@@ -140,8 +140,15 @@ module Hecks
140
140
  end
141
141
 
142
142
  def decode_for(aggregate, row)
143
+ decode_fields(fields_for(aggregate), aggregate, row)
144
+ end
145
+
146
+ # The field list a row decodes against: every declared attribute,
147
+ # plus the lifecycle field and any `projects` fields not already
148
+ # among them (both read back raw — see `decode_fields`).
149
+ def fields_for(aggregate)
143
150
  fields = aggregate.attributes.map { |attribute| [attribute.name, attribute] }
144
- if (lifecycle = aggregate.lifecycle) && !fields.any? { |name, _| name == lifecycle.field }
151
+ if (lifecycle = aggregate.lifecycle) && fields.none? { |name, _| name == lifecycle.field }
145
152
  fields << [lifecycle.field, nil]
146
153
  end
147
154
  # `projects` FIELDS (S12, ADR 0025) NEED READING BACK TOO — `project`
@@ -154,8 +161,16 @@ module Hecks
154
161
  aggregate.projected_fields.each do |field|
155
162
  fields << [field.name, nil] unless fields.any? { |name, _| name == field.name }
156
163
  end
164
+ fields
165
+ end
166
+
167
+ def decode_fields(fields, aggregate, row)
157
168
  fields.each_with_object({}) do |(name, attribute), state|
158
169
  raw = row[name.to_s]
170
+ # rubocop:disable Lint/DuplicateBranch -- the nil-attribute and
171
+ # reference-id branches both just answer `raw`, coincidentally, for
172
+ # two unrelated reasons (see each branch's own comment); merging
173
+ # them would blur that distinction.
159
174
  state[name] =
160
175
  if attribute.nil?
161
176
  raw
@@ -165,6 +180,7 @@ module Hecks
165
180
  # A reference is a scalar id — see Codec#decode.
166
181
  raw
167
182
  end
183
+ # rubocop:enable Lint/DuplicateBranch
168
184
  end
169
185
  end
170
186
  end
@@ -10,7 +10,7 @@ module Hecks
10
10
  def create_aggregate_table!
11
11
  columns = persisted_fields.map { |field| "#{quote_ident(field[:name])} #{field[:sql_type]}" }
12
12
  @db.execute(
13
- "CREATE TABLE IF NOT EXISTS #{quoted_table} (id TEXT PRIMARY KEY#{columns.empty? ? '' : ', '}#{columns.join(', ')})"
13
+ "CREATE TABLE IF NOT EXISTS #{quoted_table} (id TEXT PRIMARY KEY#{', ' unless columns.empty?}#{columns.join(', ')})"
14
14
  )
15
15
  # RIGHT HERE, NOT AS A SEPARATE STEP IN `Sqlite#initialize` —
16
16
  # `create_aggregate_table!` is the one piece of DDL `D1`
@@ -96,6 +96,28 @@ module Hecks
96
96
  raise unless e.message.include?("duplicate column name")
97
97
  end
98
98
 
99
+ def create_outbox_table!
100
+ @db.execute(<<~SQL)
101
+ CREATE TABLE IF NOT EXISTS hecks_outbox (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ delivery_id TEXT NOT NULL UNIQUE,
104
+ event_uid TEXT NOT NULL,
105
+ aggregate TEXT NOT NULL,
106
+ domain TEXT NOT NULL,
107
+ kind TEXT NOT NULL,
108
+ consumer TEXT NOT NULL,
109
+ event TEXT NOT NULL,
110
+ status TEXT NOT NULL DEFAULT 'pending',
111
+ attempts INTEGER NOT NULL DEFAULT 0,
112
+ error TEXT,
113
+ enqueued_at TEXT NOT NULL,
114
+ claimed_at TEXT,
115
+ settled_at TEXT
116
+ )
117
+ SQL
118
+ @db.execute("CREATE INDEX IF NOT EXISTS idx_hecks_outbox_status ON hecks_outbox(aggregate, status)")
119
+ end
120
+
99
121
  def sql_type(attr)
100
122
  return "TEXT" if attr.list?
101
123
 
@@ -9,6 +9,7 @@ require_relative "../../query_specification/common/null_policy"
9
9
  require_relative "../../query_specification/common/order_by"
10
10
  require_relative "../../runtime/errors"
11
11
  require_relative "../../runtime/event"
12
+ require_relative "../../runtime/outbox"
12
13
  require_relative "../../runtime/instance"
13
14
 
14
15
  module Hecks
@@ -63,6 +64,17 @@ module Hecks
63
64
  ensure_entry_mirrors_column!
64
65
  create_event_table!
65
66
  create_saga_table!
67
+ create_outbox_table!
68
+ end
69
+
70
+ # RE-ENTRANT ON PURPOSE — `atomic_put` opens its own transaction
71
+ # and `Interpreting#run_dispatch_order` opens one around the whole
72
+ # save+emit pair; SQLite3 refuses a BEGIN inside a BEGIN, so the
73
+ # inner call joins the outer one instead. Same shape Postgres uses.
74
+ def transaction(&)
75
+ return yield if @db.transaction_active?
76
+
77
+ @db.transaction(&)
66
78
  end
67
79
 
68
80
  def table = @aggregate.storage_name
@@ -81,7 +93,10 @@ module Hecks
81
93
  order_sql = "ORDER BY id"
82
94
  if order_by
83
95
  name = order_by.to_s.split(".").first
84
- raise Runtime::WiringError, "#{@aggregate.name} has no attribute #{order_by.inspect} to order by" unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
96
+ unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
97
+ raise Runtime::WiringError,
98
+ "#{@aggregate.name} has no attribute #{order_by.inspect} to order by"
99
+ end
85
100
 
86
101
  spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
87
102
  order_sql = "ORDER BY #{order_clause(spec, nil)}"
@@ -142,8 +157,10 @@ module Hecks
142
157
 
143
158
  def save(instance)
144
159
  entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
145
- append(entry)
146
- project(entry)
160
+ transaction do
161
+ append(entry)
162
+ project(entry)
163
+ end
147
164
  end
148
165
 
149
166
  # The outcome lookup, journal append and snapshot replacement share one
@@ -151,7 +168,7 @@ module Hecks
151
168
  # adapter-native operation owns both concurrency and outcome reporting.
152
169
  def atomic_put(entry, insert_only: false)
153
170
  status = nil
154
- @db.transaction do
171
+ transaction do
155
172
  exists = !@db.get_first_value("SELECT 1 FROM #{quoted_table} WHERE id = ?", [entry.id.to_s]).nil?
156
173
  if insert_only && exists
157
174
  status = :conflicted
@@ -202,11 +219,63 @@ module Hecks
202
219
  # either way; a domain that wants an obviously-named saga store
203
220
  # already gets one by sharing `database` across its aggregates,
204
221
  # the recommended, common case.
222
+ # THE OUTBOX — see `Runtime::Outbox`. Rows land in the SAME
223
+ # database as this aggregate (the only way the enqueue shares the
224
+ # save's transaction), keyed by the aggregate's storage name so an
225
+ # adapter instance only ever reads back its own rows even when
226
+ # several aggregates share one file. `INSERT OR IGNORE` on the
227
+ # UNIQUE delivery_id makes a re-enqueue of the same (event,
228
+ # consumer) a no-op; `outbox_claim`'s `WHERE status = 'pending'`
229
+ # is the compare-and-set that lets exactly one relay win a row.
230
+ def outbox_enqueue(rows)
231
+ rows.filter_map do |row|
232
+ @db.execute(
233
+ "INSERT OR IGNORE INTO hecks_outbox (delivery_id, event_uid, aggregate, domain, kind, consumer, event, " \
234
+ "status, attempts, enqueued_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)",
235
+ [row.delivery_id, row.event_uid, row.aggregate, row.domain, row.kind, row.consumer,
236
+ JSON.generate(row.event), Time.now.utc.iso8601]
237
+ )
238
+ next nil if @db.changes.zero?
239
+
240
+ row.id = @db.last_insert_row_id
241
+ row.status = "pending"
242
+ row
243
+ end
244
+ end
245
+
246
+ def outbox_claim(id)
247
+ @db.execute(
248
+ "UPDATE hecks_outbox SET status = 'claimed', attempts = attempts + 1, claimed_at = ? " \
249
+ "WHERE id = ? AND status = 'pending'",
250
+ [Time.now.utc.iso8601, id]
251
+ )
252
+ @db.changes == 1
253
+ end
254
+
255
+ def outbox_settle(id, status:, error: nil)
256
+ @db.execute(
257
+ "UPDATE hecks_outbox SET status = ?, error = ?, settled_at = ? WHERE id = ?",
258
+ [status.to_s, error, Time.now.utc.iso8601, id]
259
+ )
260
+ @db.changes == 1
261
+ end
262
+
263
+ def outbox_rows(status: nil)
264
+ sql = "SELECT * FROM hecks_outbox WHERE aggregate = ?"
265
+ binds = [table]
266
+ if status
267
+ sql << " AND status = ?"
268
+ binds << status.to_s
269
+ end
270
+ @db.execute("#{sql} ORDER BY id", binds).map { |row| outbox_row(row) }
271
+ end
272
+
205
273
  def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
206
274
  @db.execute(
207
- "INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
208
- "VALUES (?, ?, ?, ?, ?, ?)",
209
- [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
275
+ "INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, " \
276
+ "completed_compensations) VALUES (?, ?, ?, ?, ?, ?)",
277
+ [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
278
+ JSON.generate(completed_compensations)]
210
279
  )
211
280
  end
212
281
 
@@ -221,7 +290,8 @@ module Hecks
221
290
  return enum_for(:each_saga) unless block_given?
222
291
 
223
292
  @db.execute(
224
- "SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = ?",
293
+ "SELECT process_manager, correlation, state, memory, completed_compensations " \
294
+ "FROM hecks_saga_instances WHERE domain = ?",
225
295
  [@domain]
226
296
  ).each do |row|
227
297
  yield row["process_manager"], row["correlation"], row["state"],
@@ -234,6 +304,15 @@ module Hecks
234
304
 
235
305
  # ── SqlQueryBuilder's dialect hooks ─────────────────────────────
236
306
 
307
+ def outbox_row(row)
308
+ Runtime::Outbox::Row.new(
309
+ id: row["id"], delivery_id: row["delivery_id"], event_uid: row["event_uid"], aggregate: row["aggregate"],
310
+ domain: row["domain"], kind: row["kind"], consumer: row["consumer"],
311
+ event: JSON.parse(row["event"], symbolize_names: true), status: row["status"],
312
+ attempts: row["attempts"].to_i, error: row["error"]
313
+ )
314
+ end
315
+
237
316
  def select_list = "*"
238
317
  def from_relation = quoted_table
239
318
  def dialect_name = "SQLite"
@@ -1,4 +1,7 @@
1
1
  module Hecks
2
+ # Reopened here purely to require in every driven adapter under
3
+ # adapters/driven/ below (see the comment inside for what "driven side"
4
+ # means) — the actual namespace is declared in adapters.rb.
2
5
  module Adapters
3
6
  # The driven side — every store or reader an adapter declaration can
4
7
  # bind. A small adapter is one file beside this one; sqlite, postgres
@@ -32,6 +35,7 @@ require_relative "driven/postgres"
32
35
  Hecks::Adapters.autoload(:PostgresEra, "hecks/ports/persistence/plugins/era")
33
36
  require_relative "driven/lambda"
34
37
  require_relative "driven/heki"
38
+ require_relative "driven/local_storage"
35
39
  require_relative "driven/prism"
36
40
  require_relative "driven/folder"
37
41
  require_relative "driven/d1"
@@ -1,4 +1,8 @@
1
1
  module Hecks
2
+ # Namespace for every driven-side adapter (Memory, Folder, SQLite,
3
+ # Postgres, Heki, ...) a `persisted_by`/port binding can resolve to;
4
+ # concrete adapters are required in below from adapters/driven and
5
+ # register themselves into this namespace as they load.
2
6
  module Adapters
3
7
  end
4
8
  end
@@ -11,6 +11,10 @@ module Hecks
11
11
  # inside a boot, over a suite that was never going to be scoped.
12
12
  class Malformed < StandardError; end
13
13
 
14
+ # The `test "description" do ... end` block's own receiver — collects
15
+ # `tests`/`setup`/`input`/`expect` calls and builds a `TestCase` (ir.rb).
16
+ # `validate_expect!` (private, below) is where a malformed or empty
17
+ # `expect` is refused at build time rather than silently passing later.
14
18
  class TestCaseBuilder
15
19
  def initialize(description)
16
20
  @description = description
@@ -76,6 +80,9 @@ module Hecks
76
80
  end
77
81
  end
78
82
 
83
+ # The top-level `Hecks.behaviors "Name" do ... end` receiver — collects
84
+ # `vision`/`loads`/`test` calls and builds a `BehaviorsSuite` (ir.rb),
85
+ # refusing to build one missing either `vision` or `loads` (`#build`).
79
86
  class BehaviorsBuilder
80
87
  def initialize(name, source_path:)
81
88
  @name = name
@@ -38,6 +38,8 @@ require_relative "../ports/persistence/binding_policy"
38
38
  # always on and `emits:` is expected to see them.
39
39
  module Hecks
40
40
  module Behaviors
41
+ # See this file's own header above for what this module does and the
42
+ # two deliberate deviations from the prior port it's built from.
41
43
  module Expectations
42
44
  module_function
43
45
 
@@ -75,6 +77,10 @@ module Hecks
75
77
  # loop or a long rspec session actually relies on. `runtime:` lets
76
78
  # a caller that already holds a booted runtime (a spec, a REPL)
77
79
  # hand it in; it is reset the same way.
80
+ # NOT frozen — a real cache, mutated below (`RUNTIMES[key] ||=
81
+ # boot_and_guard(files)`) and by #reset!. False positive for
82
+ # Style/MutableConstant.
83
+ # rubocop:disable-next Style/MutableConstant
78
84
  RUNTIMES = {}
79
85
  RUNTIMES_LOCK = Mutex.new
80
86
  private_constant :RUNTIMES, :RUNTIMES_LOCK
@@ -137,11 +143,17 @@ module Hecks
137
143
  before = runtime.registry.event_log.length
138
144
  result = dispatch_command(runtime, verb, test.input)
139
145
 
140
- return fail_result(test, "expected refused: #{test.expect[:refused].inspect} but dispatch succeeded") if test.expect.key?(:refused)
146
+ if test.expect.key?(:refused)
147
+ return fail_result(test,
148
+ "expected refused: #{test.expect[:refused].inspect} but dispatch succeeded")
149
+ end
141
150
 
142
151
  if (expected_emits = test.expect[:emits])
143
152
  actual = runtime.registry.event_log[before..].map(&:name)
144
- return fail_result(test, "expected emits: #{expected_emits.inspect}, got #{actual.inspect}") unless actual == expected_emits
153
+ unless actual == expected_emits
154
+ return fail_result(test,
155
+ "expected emits: #{expected_emits.inspect}, got #{actual.inspect}")
156
+ end
145
157
  end
146
158
 
147
159
  check_ok(test) || check_fields(test, settled_state(runtime, verb, result)) || pass_result(test)
@@ -205,7 +217,10 @@ module Hecks
205
217
  def run_query(test, runtime, verb)
206
218
  rows = runtime.query(verb, **test.input)
207
219
 
208
- return fail_result(test, "expected refused: #{test.expect[:refused].inspect} but the query succeeded") if test.expect.key?(:refused)
220
+ if test.expect.key?(:refused)
221
+ return fail_result(test,
222
+ "expected refused: #{test.expect[:refused].inspect} but the query succeeded")
223
+ end
209
224
 
210
225
  if (expected = test.expect[:count])
211
226
  count = if rows.is_a?(Array)
@@ -307,6 +322,14 @@ module Hecks
307
322
  def qualify(command, on_aggregate, bluebooks, kind:)
308
323
  return command.to_s if command.to_s.include?(".")
309
324
 
325
+ candidates = qualify_candidates(command, on_aggregate, bluebooks, kind)
326
+ disambiguate_qualified_name(candidates, command, kind, bluebooks)
327
+ end
328
+
329
+ # THE SEARCH — every (bluebook, aggregate) pair that declares a
330
+ # command/query named `command`, narrowed to `on_aggregate` by name
331
+ # when given.
332
+ def qualify_candidates(command, on_aggregate, bluebooks, kind)
310
333
  members = kind == :query ? :queries : :commands
311
334
  pairs =
312
335
  if on_aggregate
@@ -314,8 +337,13 @@ module Hecks
314
337
  else
315
338
  bluebooks.flat_map { |bb| bb.aggregates.map { |agg| [bb, agg] } }
316
339
  end
317
- candidates = pairs.select { |_, agg| agg.public_send(members).any? { |m| m.hecks_name == command.to_s } }
340
+ pairs.select { |_, agg| agg.public_send(members).any? { |m| m.hecks_name == command.to_s } }
341
+ end
318
342
 
343
+ # THE REPORT — zero candidates and more-than-one candidates both
344
+ # refuse (with a different message); exactly one resolves to its
345
+ # dotted FQN.
346
+ def disambiguate_qualified_name(candidates, command, kind, bluebooks)
319
347
  case candidates.size
320
348
  when 0
321
349
  raise ArgumentError, "no aggregate among #{bluebooks.map(&:name).inspect} declares a #{kind} " \
@@ -16,6 +16,8 @@ require_relative "../behaviors"
16
16
  # end
17
17
  module Hecks
18
18
  module Behaviors
19
+ # See this file's own header above for what `describe_file` does and
20
+ # how a consumer wires it into their own `bundle exec rspec` run.
19
21
  module RSpec
20
22
  module_function
21
23
 
@@ -6,6 +6,10 @@ require_relative "expectations"
6
6
  # that actually touches a live runtime; this file finds `.behaviors`
7
7
  # files, loads them, and maps their tests through it.
8
8
  module Hecks
9
+ # See this file's own header above for `.run`/`.run_all`/`.parse` — the
10
+ # discovery-and-aggregation half of the `.behaviors` authoring surface;
11
+ # `Expectations` (expectations.rb) is the impure edge that actually runs
12
+ # one test against a live runtime.
9
13
  module Behaviors
10
14
  FileResult = Struct.new(:path, :parse_error, :runs, keyword_init: true)
11
15
  SweepResult = Struct.new(:root, :files_swept, :files, :summary, keyword_init: true)