hecks 1.0.1 → 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 +13 -2
  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 +57 -21
  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 +49 -473
  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 +108 -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 -1020
  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 +21 -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 +55 -5
  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 +16 -2
@@ -1,14 +1,25 @@
1
1
  require_relative "word_gate"
2
+ require_relative "aggregate_builder/sealing"
2
3
  module Hecks
3
4
  module Bluebook
4
5
  module DSL
6
+ # The `aggregate "Name" do ... end` receiver — the largest DSL builder
7
+ # in the language, collecting everything an aggregate declares
8
+ # (attributes, value objects, entities, commands, queries, policies,
9
+ # invariants, preconditions, projected fields, its lifecycle and
10
+ # identity) and assembling the final `Aggregate` IR in `#build`.
11
+ # `entity`/`command`/`query` only QUEUE a descriptor during
12
+ # `instance_eval` (`#drain_pending!` builds them, in order, once the
13
+ # whole block has run) so a later-declared piece can still be
14
+ # referenced by an earlier line — see `#drain_pending!`'s own header.
5
15
  class AggregateBuilder
6
- GRAMMAR_CONTEXT = "Aggregate"
16
+ GRAMMAR_CONTEXT = "Aggregate".freeze
7
17
 
8
18
  include AttributeCollector
9
19
  include IdentityDeclaration
10
20
  include RuleReference
11
21
  include WordGate
22
+ include Sealing
12
23
 
13
24
  def initialize(name, chapter_named_givens: {}, chapter_pending_givens: [],
14
25
  chapter_entity_named_givens: {}, chapter_entity_pending_givens: [])
@@ -156,12 +167,11 @@ module Hecks
156
167
  # so no BOOTSTRAP_CALLS_FALLBACK entry is needed, unlike
157
168
  # `attribute`/`role`.
158
169
  def has_many_impl(type, as: nil, **legacy_options)
159
- if MetaValidator.shadow_parsing?
160
- return legacy_has_many(type, as: as, optional: legacy_options.fetch(:optional, false))
161
- end
170
+ return legacy_has_many(type, as: as, optional: legacy_options.fetch(:optional, false)) if MetaValidator.shadow_parsing?
162
171
 
163
172
  unless legacy_options.empty?
164
- raise Malformed, "#{@name}.has_many takes no #{legacy_options.keys.first}: — an empty list already means none"
173
+ raise Malformed,
174
+ "#{@name}.has_many takes no #{legacy_options.keys.first}: — an empty list already means none"
165
175
  end
166
176
 
167
177
  plural = Naming.demodulise(type)
@@ -189,8 +199,8 @@ module Hecks
189
199
  optional: optional)
190
200
  end
191
201
 
192
- def lifecycle_impl(field, default:, &block)
193
- @lifecycle = LifecycleBuilder.build(field, default: default, &block)
202
+ def lifecycle_impl(field, default:, &)
203
+ @lifecycle = LifecycleBuilder.build(field, default: default, &)
194
204
  end
195
205
 
196
206
  # A piece is declared IN this aggregate — its owner is stamped by
@@ -223,8 +233,8 @@ module Hecks
223
233
  @pending_queries << [name, block]
224
234
  end
225
235
 
226
- def policy_impl(name, &block)
227
- reaction = PolicyBuilder.build(name, &block)
236
+ def policy_impl(name, &)
237
+ reaction = PolicyBuilder.build(name, &)
228
238
  reaction.aggregate = @name
229
239
  @policies << reaction
230
240
  end
@@ -242,8 +252,37 @@ module Hecks
242
252
  # into THIS aggregate's own `@value_objects`, the identical move
243
253
  # `@value_objects + closed_sets` already makes for the aggregate's
244
254
  # own direct attributes (see this file's other 5 call sites).
245
- def value_object(name, &block)
255
+ # `type` — THE BARE SHORTHAND (single-attribute value objects):
256
+ # `value_object :Price, Integer` declares a value object with
257
+ # exactly one attribute, NAMED `value`, of that type — pure sugar
258
+ # for `value_object("Price") { attribute :value, Integer }`,
259
+ # routed through the SAME `attribute_impl` the block form's own
260
+ # `attribute` line reaches (so the quoted-text-type refusal,
261
+ # `one_of(...)`/`list_of(...)` synthesis, everything an attribute
262
+ # line already does, applies unchanged rather than being
263
+ # re-derived here). The name `value` is not arbitrary: a
264
+ # single-attribute value object is a NAME for a scalar, not a
265
+ # genuine group ([[feedback_name_the_scalar_field]], `Behaviour::
266
+ # ValueObject#sole_attribute`), and `value` is what the language
267
+ # guarantees EVERY sole field answers to at runtime regardless of
268
+ # its declared name (`Runtime::Value#method_missing`'s alias) —
269
+ # so the shorthand simply declares it under the canonical name
270
+ # directly. Type AND block together are refused: the block exists
271
+ # to say what the fields are, and the type just said it — two
272
+ # answers to one question is an authoring error, never a merge.
273
+ # NEITHER type NOR block keeps its historical behavior untouched
274
+ # (an empty attribute list — judged, or not, by the language
275
+ # downstream, the same as before this parameter existed).
276
+ def value_object(name, type = nil, &block)
277
+ if type && block
278
+ raise Malformed,
279
+ "#{name} declares both a type (#{type.inspect}) and a block — " \
280
+ "value_object #{name.inspect}, Type is sugar for a block declaring " \
281
+ "exactly one attribute named :value; write one form or the other, never both"
282
+ end
283
+
246
284
  builder = ValueObjectBuilder.new(name, owner_value_objects: @value_objects + closed_sets)
285
+ builder.attribute_impl(:value, type) if type
247
286
  builder.instance_eval(&block) if block
248
287
  @value_objects << builder.build
249
288
  @value_objects.concat(builder.closed_sets)
@@ -549,469 +588,6 @@ module Hecks
549
588
  def legacy_has_one(type, as:, optional: false)
550
589
  reference_to_impl(type, as: as || Naming.snake(Naming.demodulise(type)).to_sym, optional: optional)
551
590
  end
552
-
553
- # Every reference is told which Aggregate declares it, so it can
554
- # find the chapter and resolve its target.
555
- #
556
- # Stamped HERE, at build, rather than at `reference_to`, because a command
557
- # builder does not hold the aggregate and should not learn to. And
558
- # deliberately across every list that can carry one — a reference the walk
559
- # missed would resolve to nil, and `resolve_references` SKIPS a nil target,
560
- # so the guarantee would go quiet instead of going red. That is the exact
561
- # shape of the bug that let an Account belong to an unregistered customer
562
- # fourteen times over.
563
- def stamp_references(ir)
564
- reference_bearing_attributes.each { |attribute| attribute.type.declared_in = ir }
565
- end
566
-
567
- # AN OWNED PIECE'S OWN `reference_to` IS AN EDGE THIS AGGREGATE
568
- # POINTS ACROSS TOO (S9, ADR 0025 — "entity/aggregate shared
569
- # vocabulary") — a ring closing through a contained piece (Board
570
- # -> Board::Card -> Product -> Board) is the same "no boundary
571
- # anyone can reason about alone" `validate_no_bidirectional_
572
- # references!` already refuses for a direct aggregate-to-
573
- # aggregate ring; it was invisible before this because only
574
- # `AggregateBuilder#reference_to` ever fed `@reference_targets`,
575
- # never `EntityBuilder#reference_to`. Command/query reference
576
- # ARGUMENTS are deliberately excluded — they are data flowing
577
- # through a dispatch, not persisted state the graph a cycle
578
- # means anything over.
579
- def entity_reference_targets
580
- @entities.flat_map { |entity| entity.attributes.select(&:reference?).map { |a| a.type.target_name.to_s } }
581
- end
582
-
583
- def reference_bearing_attributes
584
- lists = [attributes, *@commands.map(&:attributes), *@queries.map(&:attributes)]
585
- @entities.each do |entity|
586
- lists << entity.attributes
587
- lists.concat(entity.commands.map(&:attributes))
588
- lists.concat(entity.queries.map(&:attributes))
589
- end
590
-
591
- lists.flatten.select(&:reference?)
592
- end
593
-
594
- # A mutation must name a field the aggregate actually HAS.
595
- #
596
- # NOT moved to the language, and deliberately so. The language says only
597
- # `given("a mutation names a target") { !target.value.to_s.empty? }` —
598
- # non-emptiness — because saying more means reaching a list that lives on
599
- # a DIFFERENT root : a command's changes hang off Command, the fields they
600
- # name hang off Aggregate, and a given is a closed predicate over its own
601
- # state. Aggregate.Seal is the right shape and cannot see commands ; the
602
- # reference trick that rescued "attributes use value-object types" needs a
603
- # root to point at, and an aggregate's fields are a value-object list, not
604
- # roots. This is the second rule that cannot port for that reason — the
605
- # first is read-model uniqueness — and both wait on the same thing : a
606
- # quantifier, or fields promoted to roots.
607
- #
608
- # So it lives here, at build, where every declaration is present. Found by
609
- # writing `then_set :disputed_by` on CardPayment before the field existed :
610
- # it wrote into nothing, refused nothing, and every check stayed green.
611
- # A DEFAULT FILLS THE SHAPE IT IS DECLARED ON, or it fills nothing.
612
- #
613
- # `attribute :cover, one_of("covered", "open"), default: "open"` builds
614
- # cleanly and then refuses EVERY create at dispatch — "cover is a Cover,
615
- # pass its fields as an object" — because the value object wants its
616
- # fields and got a bare string. The bluebook is wrong at the line where
617
- # it is written and says so nowhere near it.
618
- #
619
- # It cost a corpus member 33 refusals out of 40 steps, with every gate
620
- # green throughout: the refusals were perfectly consistent, which is
621
- # consistency about nothing. `till.bluebook` has always had the right shape
622
- # — `default: { cents: 0 }`.
623
- #
624
- # A PRIMITIVE takes a scalar and a VALUE OBJECT takes its fields, so the
625
- # test is simply which one the type names. Nothing here guesses at the
626
- # keys: a default that is a Hash is left to `Value.for_attribute`, which
627
- # is where a wrong FIELD belongs.
628
- def seal_defaults
629
- # `closed_sets` TOO, not only `@value_objects` — the exact gap
630
- # this method's own comment names: an inline `one_of(...)`
631
- # synthesises its value object through `closed_sets`
632
- # (AttributeCollector#synthesise_closed_set), never installed
633
- # into `@value_objects` until `#build` merges them (see
634
- # `#build`'s own `@value_objects + closed_sets`, and
635
- # `declared_value_object`'s identical merge). Checking
636
- # `@value_objects` alone made this exact attribute — a bare
637
- # default on an inline closed set — invisible to the one
638
- # check meant to catch it.
639
- shapes = (@value_objects + closed_sets).map { |shape| shape.hecks_name.to_s }
640
-
641
- attributes.each do |attribute|
642
- next if attribute.default.nil? || attribute.default.is_a?(Hash)
643
- next unless shapes.include?(attribute.type.to_s)
644
-
645
- raise Malformed,
646
- "#{@name}.#{attribute.name} defaults to #{attribute.default.inspect}, but " \
647
- "#{attribute.type} is a value object — a default fills its FIELDS " \
648
- "(default: { ... }), and a bare value refuses every create instead"
649
- end
650
- end
651
-
652
- # A command's `from:` guard needs a lifecycle field to check
653
- # against — declared at BUILD time (S10, ADR 0025), the same
654
- # point every other "does this actually resolve" check in this
655
- # file runs, rather than left to crash `enforce_lifecycle_
656
- # guard` the first time such a command is ever dispatched.
657
- def seal_lifecycle_guards
658
- return if @lifecycle
659
-
660
- @commands.each do |command|
661
- next unless command.from
662
-
663
- raise Malformed,
664
- "#{@name}.#{command.hecks_name} guards from: #{Array(command.from).inspect}, but " \
665
- "#{@name} declares no lifecycle — from: checks a lifecycle field, and there is " \
666
- "none here to check"
667
- end
668
- end
669
-
670
- # `projects`'s OWN half of "does this actually resolve" (S12,
671
- # ADR 0025) — the LOCAL half only: `reference` must name a real
672
- # reference-typed attribute this aggregate declares, and
673
- # `name` must not collide with an attribute already declared
674
- # (a projected field is its own kind of field, never a second
675
- # spelling of one that already exists). The TARGET aggregate's
676
- # own field is checked separately, once every aggregate in the
677
- # chapter is real — see BluebookBuilder#validate_projected_
678
- # fields!'s own comment for why that half cannot happen here.
679
- def seal_projected_fields
680
- declared = attributes.map { |attribute| attribute.name.to_sym }
681
-
682
- @projected_fields.each do |field|
683
- if declared.include?(field.name)
684
- raise Malformed,
685
- "#{@name}.projects :#{field.name} names a field #{@name} already declares — " \
686
- "a projected field is never a second spelling of one that already exists"
687
- end
688
-
689
- reference_attribute = attributes.find { |attribute| attribute.name == field.reference }
690
- unless reference_attribute&.reference?
691
- raise Malformed,
692
- "#{@name}.projects :#{field.name} reads through #{field.reference.inspect}, which " \
693
- "#{@name} never declares as a reference_to — projects reads through a REFERENCE, " \
694
- "never a value object or a scalar"
695
- end
696
- end
697
- end
698
-
699
- def seal_mutation_targets
700
- known = attributes.map { |attribute| attribute.name.to_sym }
701
- known << @lifecycle.field.to_sym if @lifecycle
702
-
703
- @commands.each do |command|
704
- command.mutations.each do |mutation|
705
- # `:delegate` — CommandBuilder#delegates_to's own comment —
706
- # targets no field of THIS aggregate at all; its `target`
707
- # names an "Entity.Command" pair instead, checked when the
708
- # command builds (`delegates_to`'s own `rpartition` guard)
709
- # and again at dispatch time (`CommandInterpreter
710
- # #step_delegate_to_entity`, which refuses a real one that
711
- # names no such entity or command). Sealing THIS check
712
- # against it would refuse every delegating command outright.
713
- # `:corrects` — CommandBuilder#corrects_impl's own comment —
714
- # targets an EVENT name, not a field either; checked instead
715
- # by `seal_correction_targets`, below.
716
- next if [:delegate, :corrects].include?(mutation.op)
717
- next if known.include?(mutation.target.to_sym)
718
-
719
- raise Malformed,
720
- "#{@name}.#{command.hecks_name} sets #{mutation.target}, which #{@name} " \
721
- "never declares — a mutation into a field that does not exist " \
722
- "writes nothing and refuses nothing"
723
- end
724
- end
725
- end
726
-
727
- # `corrects` — CommandBuilder#corrects_impl's own comment. Runs
728
- # once every command in the aggregate is known (the same reason
729
- # this is a `seal_*` step rather than living in `corrects_impl`
730
- # itself — a command cannot see its own siblings' `emits` while
731
- # it is still being built). Two things are checked:
732
- #
733
- # 1. The named event must be something a SIBLING command here
734
- # actually `emits` — naming an event nothing in this aggregate
735
- # ever announces is a build-time authoring error. (Whether
736
- # THIS record has actually emitted it YET is the dispatch-time
737
- # half — CommandRules::Admissibility#enforce_correction_target.)
738
- #
739
- # 2. `reverses: true` derives the corrective `sets` from the
740
- # ORIGINAL command's own mutations, rather than the author
741
- # writing them — but only when every one of those mutations is
742
- # STRUCTURALLY invertible with no runtime data: increment/
743
- # decrement, same argument, opposite verb (`sign_for`'s own
744
- # +1/-1 pair — CommandRules::Arithmetic applies `current +
745
- # sign * amount`, so the SAME source with the OPPOSITE sign
746
- # undoes it exactly). Nothing else qualifies today: `set` has
747
- # no such rule at all — inverting it needs the SPECIFIC prior
748
- # value at the moment the original fired, which is per-
749
- # instance runtime data no build-time derivation can have;
750
- # `multiply`/`clamp` are lossy by design (a clamped value's
751
- # own pre-clamp magnitude is not recoverable from the mutation
752
- # at all); `append`/`remove` LOOK symmetric but are not
753
- # reliably so — `append`'s source is a per-field binding hash
754
- # (`append: { name: :name, amount: :amount }`), `remove`'s is
755
- # a single resolved value to match by equality
756
- # (MutationApplier#removed), and collapsing one shape into the
757
- # other correctly needs the target list's own value-object
758
- # field names, not just the mutation's own recorded shape — a
759
- # real gap, left for a follow-on round rather than guessed at
760
- # here. Refuses rather than silently deriving something wrong
761
- # — see docs/decisions/ for the ADR that draws this exact
762
- # line.
763
- def seal_correction_targets
764
- inverse_op = { increment: :decrement, decrement: :increment }
765
- emitted_by = Hash.new { |hash, key| hash[key] = [] }
766
- @commands.each { |command| command.emits.each { |event_name| emitted_by[event_name] << command } }
767
-
768
- @commands.each do |command|
769
- correction = command.mutations.find { |mutation| mutation.op == :corrects }
770
- next unless correction
771
-
772
- event = correction.target
773
- sources = emitted_by[event]
774
- if sources.empty?
775
- raise Malformed,
776
- "#{@name}.#{command.hecks_name} corrects #{event.inspect}, but nothing " \
777
- "declared on #{@name} ever emits it — corrects names a fact this " \
778
- "aggregate actually announces, not an aspiration"
779
- end
780
-
781
- next unless correction.source[:reverses]
782
-
783
- own_mutations = command.mutations.reject { |mutation| mutation.op == :corrects }
784
- if own_mutations.any?
785
- raise Malformed,
786
- "#{@name}.#{command.hecks_name} declares both corrects #{event.inspect}, " \
787
- "reverses: true AND its own sets — reverses: true means the correction " \
788
- "is DERIVED; write one or the other, never both"
789
- end
790
-
791
- derived = sources.flat_map(&:mutations).reject { |mutation| mutation.op == :corrects }
792
- unsupported = derived.reject { |mutation| inverse_op.key?(mutation.op) }
793
- if unsupported.any?
794
- raise Malformed,
795
- "#{@name}.#{command.hecks_name} corrects #{event.inspect}, reverses: " \
796
- "true, but the command(s) that emit it use " \
797
- "#{unsupported.map(&:op).uniq.join(', ')} — not statically invertible " \
798
- "(set needs the specific prior value, multiply/clamp are lossy) — " \
799
- "declare the corrective sets by hand instead"
800
- end
801
-
802
- derived.each do |mutation|
803
- command.mutations << Mutation.new(target: mutation.target, op: inverse_op.fetch(mutation.op),
804
- source: mutation.source)
805
- end
806
- end
807
- end
808
-
809
- # A query must ask about a field the aggregate actually HAS — the same
810
- # seal `then_set` gets, closing the same silence: a where over a field
811
- # nothing declares matches nothing and refuses nothing, forever, on
812
- # every adapter. Three more silences close with it. A dotted path may
813
- # reach through the value-object graph but must LAND on a scalar
814
- # member (QuerySpecification::FieldPath is the one walk every engine
815
- # now shares) — landing on a value object hands SQL a JSON object
816
- # where the reference interpreter unwraps a hash. An ordered
817
- # comparator (lt/gt/gte/lte) must land on a numeric leaf — over text
818
- # the reference interpreter quietly matches no rows while SQL
819
- # compares lexicographically. And a :symbol value must name one of
820
- # the query's own declared arguments, or it resolves to nil at
821
- # dispatch and matches nothing.
822
- ORDERED_COMPARATORS = %i[lt lte gt gte].freeze
823
-
824
- def seal_query_targets
825
- query_surfaces.each do |owner, fields, lifecycle, queries|
826
- queries.each do |query|
827
- query.wheres.each do |clause|
828
- seal_query_field(owner, query, fields, lifecycle, clause.field)
829
- seal_ordered_comparator(owner, query, fields, clause)
830
- infer_local_query_argument(query, fields, lifecycle, clause)
831
- seal_query_argument(owner, query, clause.value) unless clause.field.to_s.include?("/")
832
- end
833
- seal_query_field(owner, query, fields, lifecycle, query.order_by.field, ordering: true) if query.order_by
834
- seal_query_argument(owner, query, query.limit&.value)
835
- seal_query_argument(owner, query, query.offset&.value)
836
- end
837
- end
838
- end
839
-
840
- def query_surfaces
841
- [[@name, attributes, @lifecycle, @queries]] +
842
- @entities.map { |entity| ["#{@name}::#{entity.hecks_name}", entity.attributes, entity.lifecycle, entity.queries] }
843
- end
844
-
845
- # `/` CROSSES INTO ANOTHER RECORD, `.` WALKS FIELDS INSIDE THIS
846
- # ONE (ADR 0025, "References") — the operator answers which
847
- # kind of path this is now, not a name collision to arbitrate,
848
- # so a hop is routed to its own method before any `.`-splitting
849
- # runs at all; `seal_query_hop` below never sees a field this
850
- # one would also have tried to resolve as a local dotted walk.
851
- def seal_query_field(owner, query, fields, lifecycle, field, ordering: false)
852
- return seal_query_hop(owner, query, fields, field, ordering: ordering) if field.to_s.include?("/")
853
-
854
- name, *nested = field.to_s.split(".")
855
- attribute = fields.find { |candidate| candidate.name.to_s == name }
856
- if nested.empty? && attribute
857
- refuse_ambiguous_comparison!(owner, query, field, attribute)
858
- return
859
- end
860
- return if nested.empty? && lifecycle&.field.to_s == name
861
- return if nested.any? && attribute && scalar_path?(attribute, nested)
862
-
863
- if nested.any? && attribute && resolves?(attribute, nested)
864
- raise Malformed,
865
- "#{owner}.#{query.hecks_name} asks about #{field}, which lands on a " \
866
- "value object, not a scalar — a dotted query path ends on a scalar " \
867
- "member, or the engines answer it differently"
868
- end
869
-
870
- raise Malformed,
871
- "#{owner}.#{query.hecks_name} asks about #{field}, which #{owner} " \
872
- "never declares — a query over a field that does not exist " \
873
- "matches nothing and refuses nothing"
874
- end
875
-
876
- # ORDER BY refuses a hop OUTRIGHT, right here — unlike a WHERE
877
- # hop (deferred below), this doesn't need the target's shape to
878
- # answer: an ask is ordered by what its own answering rows
879
- # hold, and a hop answers with a candidate set, not a sort key
880
- # (see Runtime::ReferenceHop).
881
- #
882
- # A WHERE hop is only RECOGNISED here, and CHECKED LATER. The
883
- # head names one of this aggregate's own references, which is
884
- # answerable now — a Reference knows its own target_name at
885
- # declaration. What it points AT is not: stamp_references has
886
- # already run by this point, but the chapter (Bluebook, and the
887
- # owning aggregate's OWN place in it) does not exist yet, so
888
- # Reference#resolve would answer nil for every target in the
889
- # file, including ones declared above this one. The tail, and
890
- # whether the target even exists, are BluebookBuilder's
891
- # business — see validate_query_hops!, which runs once the
892
- # chapter is real, for exactly the reason
893
- # validate_no_bidirectional_references! already gives for
894
- # living at that same later point.
895
- def seal_query_hop(owner, query, fields, field, ordering:)
896
- unless QuerySpecification::HopPath.hop_head?(field, fields)
897
- raise Malformed,
898
- "#{owner}.#{query.hecks_name} asks about #{field}, which #{owner} " \
899
- "never declares — a query over a field that does not exist " \
900
- "matches nothing and refuses nothing"
901
- end
902
-
903
- return unless ordering
904
-
905
- raise Malformed,
906
- "#{owner}.#{query.hecks_name} orders by #{field}, which hops through " \
907
- "a reference — an ask is ordered by what its own answering rows " \
908
- "hold, and a hop answers with a candidate set, not a sort key"
909
- end
910
-
911
- def seal_ordered_comparator(owner, query, fields, clause)
912
- return unless ORDERED_COMPARATORS.include?(clause.op.to_s.to_sym)
913
-
914
- # A WHERE clause hopping through a reference with an ordered
915
- # comparator is legitimate ("client whose balance > 500") —
916
- # unlike ORDER BY (refused outright in seal_query_field, see
917
- # its own comment), a where-clause hop answers a real
918
- # candidate set either way, ordered or not. Deferred for the
919
- # same reason any other hop is: whether the tail is even
920
- # numeric is BluebookBuilder#validate_query_hops!'s question
921
- # to ask of the TARGET's shape, not this aggregate's own.
922
- return if clause.field.to_s.include?("/") && QuerySpecification::HopPath.hop_head?(clause.field, fields)
923
-
924
- name, *nested = clause.field.to_s.split(".")
925
- attribute = fields.find { |candidate| candidate.name.to_s == name }
926
- return if attribute &&
927
- QuerySpecification::FieldPath.numeric?(attribute, nested) { |type| declared_value_object(type) }
928
-
929
- held = attribute ? "holds no number" : "is the lifecycle field, which holds text"
930
- raise Malformed,
931
- "#{owner}.#{query.hecks_name} compares #{clause.field} with #{clause.op}, " \
932
- "but #{clause.field} #{held} — an ordered comparison needs a numeric " \
933
- "field, and over anything else the adapters answer differently or not at all"
934
- end
935
-
936
- def seal_query_argument(owner, query, value)
937
- return unless value.is_a?(Symbol)
938
- return if query.attribute(value)
939
-
940
- raise Malformed,
941
- "#{owner}.#{query.hecks_name} resolves :#{value} from its arguments, " \
942
- "but declares no #{value} attribute — an argument that does not exist " \
943
- "resolves to nil and matches nothing"
944
- end
945
-
946
- # A symbolic right-hand side is a query input. When the compared path
947
- # lands on this owner's declared shape, its type is already known and
948
- # repeating an `attribute` line inside the query adds no information.
949
- # Reference hops are resolved only after the whole chapter has been
950
- # owner-stamped; BluebookBuilder performs the identical inference for
951
- # those deferred paths.
952
- def infer_local_query_argument(query, fields, lifecycle, clause)
953
- name = clause.value
954
- return unless name.is_a?(Symbol)
955
- return if query.attribute(name)
956
- return if clause.field.to_s.include?("/")
957
-
958
- head, *nested = clause.field.to_s.split(".")
959
- leaf = if nested.empty? && lifecycle&.field.to_s == head
960
- Attribute.new(name: name, type: String)
961
- else
962
- root = fields.find { |candidate| candidate.name.to_s == head }
963
- found = root && QuerySpecification::FieldPath.leaf_attribute(root, nested) do |type|
964
- declared_value_object(type)
965
- end
966
- found && Attribute.new(name: name, type: found.type, list: found.list?)
967
- end
968
- query.attributes << leaf if leaf
969
- end
970
-
971
- # A BARE FIELD NAMING A VALUE OBJECT HAS TO SAY WHICH MEMBER IT
972
- # MEANS, when more than one could answer. The dotted case above
973
- # already refuses a path that lands on a value object rather than
974
- # a scalar; a bare name was returning unconditionally, so
975
- # `where(frequency: ...)` against a StatementFrequency
976
- # (cadence, retention_months, paper_fee_cents) compiled — and the
977
- # engines then disagreed about which member it meant, one taking
978
- # the FIRST numeric and another declining to unwrap at all.
979
- #
980
- # Unambiguous is: exactly one member, whatever its type, or
981
- # exactly one NUMERIC member among several (Money's `cents`
982
- # beside its `currency` — the reading every engine already
983
- # shared, and what the corpus relies on). Anything else names
984
- # its member with a dotted path, which already works.
985
- #
986
- # A list is exempt: `contains` over a `list_of` reads element
987
- # membership, not a scalar comparison, and has its own agreed
988
- # reading across the engines.
989
- def refuse_ambiguous_comparison!(owner, query, field, attribute)
990
- return if attribute.list?
991
-
992
- value_object = declared_value_object(attribute.type.to_s)
993
- return unless value_object
994
-
995
- members = QuerySpecification::Common::Comparison.ambiguous_members(value_object)
996
- return if members.empty?
997
-
998
- raise Malformed,
999
- "#{owner}.#{query.hecks_name} asks about #{field}, which names #{attribute.type} — " \
1000
- "it has #{members.size} members (#{members.join(', ')}) and no single one a " \
1001
- "comparison can mean; name the member (#{field}.#{members.first})"
1002
- end
1003
-
1004
- def scalar_path?(attribute, nested)
1005
- QuerySpecification::FieldPath.scalar_leaf?(attribute, nested) { |type| declared_value_object(type) }
1006
- end
1007
-
1008
- def resolves?(attribute, nested)
1009
- !QuerySpecification::FieldPath.leaf_attribute(attribute, nested) { |type| declared_value_object(type) }.nil?
1010
- end
1011
-
1012
- def declared_value_object(type_name)
1013
- (@value_objects + closed_sets).find { |shape| shape.hecks_name.to_s == type_name }
1014
- end
1015
591
  end
1016
592
  end
1017
593
  end
@@ -1,8 +1,20 @@
1
1
  module Hecks
2
2
  module Bluebook
3
3
  module DSL
4
+ # Shared mixin for every DSL builder that declares attributes
5
+ # (Aggregate/Entity/Command/Query/PortOperation/ValueObject) — the
6
+ # `attribute`/`list_of`/`one_of` words themselves, closed-set
7
+ # synthesis, duplicate-name and pattern refusals, and the
8
+ # `identified_by` field/type resolution both a construct's own
9
+ # identity and a compound key are built from.
4
10
  module AttributeCollector
5
11
  ListOf = Struct.new(:type)
12
+ # `:values` shadows Struct#values on purpose — with one member, the
13
+ # generated accessor returns the flat array of permitted values
14
+ # directly (what every caller actually wants), not Struct#values'
15
+ # own `[self.values]`. No caller anywhere reads the built-in
16
+ # behavior; verified before disabling this cop for it.
17
+ # rubocop:disable-next Lint/StructNewOverride
6
18
  OneOf = Struct.new(:values)
7
19
 
8
20
  UNSET = Object.new.freeze
@@ -261,15 +273,11 @@ module Hecks
261
273
  def resolve_identity_type!(type, as, insert_at, value_objects, context_name)
262
274
  target = Naming.demodulise(type.respond_to?(:hecks_name) ? type.hecks_name : type)
263
275
  matches = value_objects.select { |value_object| value_object.hecks_name.to_s == target }
264
- if matches.size > 1
265
- raise Malformed, "#{context_name}.identified_by names duplicate value object #{target}"
266
- end
276
+ raise Malformed, "#{context_name}.identified_by names duplicate value object #{target}" if matches.size > 1
267
277
 
268
278
  vo = type.respond_to?(:attributes) ? type : matches.first
269
279
  raise Malformed, "#{context_name}.identified_by names #{target}, which is not a declared value object" unless vo
270
- if vo.attributes.empty?
271
- raise Malformed, "#{context_name}.identified_by names #{target}, which declares no attributes"
272
- end
280
+ raise Malformed, "#{context_name}.identified_by names #{target}, which declares no attributes" if vo.attributes.empty?
273
281
 
274
282
  field = (as || Naming.snake(target)).to_sym
275
283
  if attributes.any? { |attribute| attribute.name == field }
@@ -1,6 +1,11 @@
1
1
  module Hecks
2
2
  module Bluebook
3
3
  module DSL
4
+ # What a bare `Domain::Aggregate` constant resolves to inside a
5
+ # `.hecksagon`/`.world` block (minted by `.namespace`'s own
6
+ # `const_missing`) — `method_missing` turns a bind like
7
+ # `Payment.persisted_by "Postgres"` into a queued `Bind`, and `#port`
8
+ # is the one real method, for `Aggregate.port("Name") do ... end`.
4
9
  class BindingProxy
5
10
  def self.namespace(domain, collector)
6
11
  Module.new do