hecks 0.3.0 → 1.0.1

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 (202) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sql_query_builder.rb +1 -1
  14. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  15. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  16. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  17. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  18. data/lib/hecks/adapters/driven.rb +19 -1
  19. data/lib/hecks/behaviors/dsl.rb +29 -0
  20. data/lib/hecks/behaviors/expectations.rb +62 -2
  21. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  22. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  23. data/lib/hecks/bluebook/assembly.rb +14 -1
  24. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  25. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  26. data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
  27. data/lib/hecks/bluebook/chapter.rb +21 -11
  28. data/lib/hecks/bluebook/command.rb +1 -1
  29. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  30. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  31. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  32. data/lib/hecks/bluebook/dsl/command_builder.rb +144 -3
  33. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
  34. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  35. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  36. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  37. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  39. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  40. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  41. data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
  42. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  43. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  44. data/lib/hecks/bluebook/expression/projection.json +48 -0
  45. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  46. data/lib/hecks/bluebook/expression.rb +1 -0
  47. data/lib/hecks/bluebook/hexagon.rb +1 -1
  48. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  49. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  50. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  51. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  52. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  53. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  54. data/lib/hecks/bluebook/model_check.rb +147 -6
  55. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  56. data/lib/hecks/bluebook/process_manager.rb +53 -11
  57. data/lib/hecks/bluebook/project_register.rb +25 -0
  58. data/lib/hecks/bluebook/value_object.rb +19 -2
  59. data/lib/hecks/doc/reference.rb +22 -1
  60. data/lib/hecks/facade/cli_door.rb +6 -3
  61. data/lib/hecks/facade/json_door.rb +16 -4
  62. data/lib/hecks/forms/app.rb +47 -6
  63. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  64. data/lib/hecks/forms/field_renderer.rb +11 -4
  65. data/lib/hecks/forms/html.rb +31 -0
  66. data/lib/hecks/forms/params.rb +30 -1
  67. data/lib/hecks/forms/port_argument.rb +46 -0
  68. data/lib/hecks/forms/record_renderer.rb +6 -2
  69. data/lib/hecks/forms/record_table.rb +6 -1
  70. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  71. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  72. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  73. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  74. data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
  75. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  76. data/lib/hecks/fuzzing/properties.rb +52 -6
  77. data/lib/hecks/fuzzing/replay.rb +51 -18
  78. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  79. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  80. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  81. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  82. data/lib/hecks/grammar/evolve.rb +33 -0
  83. data/lib/hecks/grammar/expression.bluebook +8 -5
  84. data/lib/hecks/grammar/expression_operators.json +497 -1
  85. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  86. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  87. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  88. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  89. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  90. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  91. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  92. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  93. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  94. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  95. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  96. data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
  97. data/lib/hecks/language/oidc.json +20 -0
  98. data/lib/hecks/language/port.bluebook +30 -2
  99. data/lib/hecks/naming.rb +54 -1
  100. data/lib/hecks/ports/access_control.port +7 -2
  101. data/lib/hecks/ports/access_control.rb +1 -1
  102. data/lib/hecks/ports/agent.port +6 -2
  103. data/lib/hecks/ports/agent.rb +1 -1
  104. data/lib/hecks/ports/authentication.port +4 -2
  105. data/lib/hecks/ports/authentication.rb +1 -1
  106. data/lib/hecks/ports/authorization.port +5 -2
  107. data/lib/hecks/ports/authorization.rb +14 -11
  108. data/lib/hecks/ports/clock.port +3 -2
  109. data/lib/hecks/ports/clock.rb +1 -1
  110. data/lib/hecks/ports/extraction.port +3 -2
  111. data/lib/hecks/ports/extraction.rb +1 -1
  112. data/lib/hecks/ports/identity_assignment.port +3 -2
  113. data/lib/hecks/ports/identity_assignment.rb +1 -1
  114. data/lib/hecks/ports/identity_generation.port +3 -2
  115. data/lib/hecks/ports/identity_generation.rb +1 -1
  116. data/lib/hecks/ports/identity_resolution.port +3 -2
  117. data/lib/hecks/ports/identity_resolution.rb +1 -1
  118. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  119. data/lib/hecks/ports/persistence/execution.rb +6 -1
  120. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  121. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  122. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  123. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  129. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  130. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  131. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  132. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  133. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  134. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  135. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  136. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  137. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  138. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  139. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  140. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  141. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  142. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  143. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  144. data/lib/hecks/ports/persistence.rb +1 -1
  145. data/lib/hecks/ports/projection.rb +20 -0
  146. data/lib/hecks/projections/diagrams.rb +230 -1
  147. data/lib/hecks/projections/shape.rb +15 -2
  148. data/lib/hecks/projector/cli_projector.rb +10 -1
  149. data/lib/hecks/projector/exporter.rb +19 -2
  150. data/lib/hecks/query_ir.rb +19 -0
  151. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  152. data/lib/hecks/query_specification/field_path.rb +20 -2
  153. data/lib/hecks/query_specification/hop_path.rb +7 -5
  154. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  155. data/lib/hecks/runtime/boot_gates.rb +41 -0
  156. data/lib/hecks/runtime/caller.rb +19 -3
  157. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  158. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  159. data/lib/hecks/runtime/command_interpreter.rb +165 -34
  160. data/lib/hecks/runtime/command_rules/admissibility.rb +101 -16
  161. data/lib/hecks/runtime/command_rules/arithmetic.rb +40 -4
  162. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  163. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  164. data/lib/hecks/runtime/dependency_planning.rb +66 -5
  165. data/lib/hecks/runtime/dispatcher.rb +19 -4
  166. data/lib/hecks/runtime/entity_interpreter.rb +92 -14
  167. data/lib/hecks/runtime/errors.rb +22 -0
  168. data/lib/hecks/runtime/identity.rb +30 -2
  169. data/lib/hecks/runtime/instance.rb +59 -4
  170. data/lib/hecks/runtime/interpreting.rb +21 -0
  171. data/lib/hecks/runtime/loader.rb +59 -18
  172. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  173. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  174. data/lib/hecks/runtime/read_model_interpreter.rb +89 -5
  175. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  176. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  177. data/lib/hecks/runtime/registry/verification.rb +88 -0
  178. data/lib/hecks/runtime/registry.rb +69 -8
  179. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  180. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  181. data/lib/hecks/runtime/value/admission.rb +19 -1
  182. data/lib/hecks/runtime/value/coercion.rb +75 -10
  183. data/lib/hecks/runtime.rb +17 -5
  184. data/lib/hecks/storehouse.rb +632 -0
  185. data/lib/hecks/version.rb +1 -1
  186. data/lib/hecks/vocabulary.rb +6 -1
  187. data/lib/hecks.rb +28 -3
  188. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  189. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  190. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  191. metadata +49 -37
  192. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  193. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  194. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  195. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  196. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  197. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  198. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  199. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  200. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  201. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  202. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -30,17 +30,27 @@ module Hecks
30
30
  IR_VERSION = 1
31
31
 
32
32
  emits_ir(
33
- ir_version: -> { IR_VERSION },
34
- name: :name,
35
- version: :version,
36
- vision: :vision,
37
- classification: :classification,
38
- aggregates: many(:aggregates),
39
- read_models: many(:read_models),
40
- policies: many(:policies),
41
- process_managers: many(:process_managers),
42
- attaches_to: :attaches_to,
43
- canonical_form: -> { Expression::CanonicalForm.table }
33
+ ir_version: -> { IR_VERSION },
34
+ name: :name,
35
+ version: :version,
36
+ vision: :vision,
37
+ classification: :classification,
38
+ # M10 — a domain rename (`formerly_known_as "OldName"`) drives a
39
+ # real Postgres schema rename at boot (EraResolver reads
40
+ # `bluebook.formerly_known_as` off the live Ruby object) and the
41
+ # meta-validator's cache key is `SHA256(JSON(bluebook.to_h))` — so
42
+ # a fact this method didn't spell was a fact two chapters
43
+ # differing ONLY by their old name could hash identically on,
44
+ # same as read-model filters before them. Spelled here for the
45
+ # same reason `version`/`vision` are: a plain field, present
46
+ # (possibly nil) rather than silently absent.
47
+ formerly_known_as: :formerly_known_as,
48
+ aggregates: many(:aggregates),
49
+ read_models: many(:read_models),
50
+ policies: many(:policies),
51
+ process_managers: many(:process_managers),
52
+ attaches_to: :attaches_to,
53
+ canonical_form: -> { Expression::CanonicalForm.table }
44
54
  )
45
55
 
46
56
  attr_reader :name, :version, :vision, :aggregates, :policies, :process_managers,
@@ -39,7 +39,7 @@ module Hecks
39
39
  # the tail, which is why a construct with a variable shape needs no
40
40
  # new mixin API.
41
41
  def to_h
42
- return super.merge(fields: appended_fields) if [:append, :delegate].include?(op)
42
+ return super.merge(fields: appended_fields) if [:append, :delegate, :corrects].include?(op)
43
43
 
44
44
  super.merge(source: classified_source)
45
45
  end
@@ -10,7 +10,8 @@ module Hecks
10
10
  include RuleReference
11
11
  include WordGate
12
12
 
13
- def initialize(name, chapter_named_givens: {}, chapter_pending_givens: [])
13
+ def initialize(name, chapter_named_givens: {}, chapter_pending_givens: [],
14
+ chapter_entity_named_givens: {}, chapter_entity_pending_givens: [])
14
15
  @name = name
15
16
  @value_objects = []
16
17
  @commands = []
@@ -37,6 +38,15 @@ module Hecks
37
38
  # here and `BluebookBuilder#resolve_pending_chapter_givens!`
38
39
  # for where it drains.
39
40
  @chapter_pending_givens = chapter_pending_givens
41
+ # ONE LEVEL WIDER STILL, PAST THE CHAPTER'S OWN AGGREGATE-LEVEL
42
+ # POOL — the chapter's own entity-scoped pool, threaded from
43
+ # `BluebookBuilder#aggregate_impl` the same way
44
+ # `@chapter_named_givens` is, and passed straight through
45
+ # (unchanged) to every top-level piece this aggregate builds
46
+ # (`#drain_pending!`). See `EntityBuilder#given_impl`'s own
47
+ # comment for what this closes.
48
+ @chapter_entity_named_givens = chapter_entity_named_givens
49
+ @chapter_entity_pending_givens = chapter_entity_pending_givens
40
50
  # DEFERRED CONSTRUCTION — `entity`/`command`/`query` push a
41
51
  # pending descriptor here instead of building immediately; see
42
52
  # `#drain_pending!`'s own comment for why.
@@ -419,6 +429,7 @@ module Hecks
419
429
  seal_defaults
420
430
  seal_lifecycle_guards
421
431
  seal_projected_fields
432
+ seal_correction_targets
422
433
 
423
434
  ir = Aggregate.new(
424
435
  name: @name,
@@ -445,8 +456,11 @@ module Hecks
445
456
  ir
446
457
  end
447
458
 
448
- def self.build(name, chapter_named_givens: {}, chapter_pending_givens: [], &block)
449
- builder = new(name, chapter_named_givens: chapter_named_givens, chapter_pending_givens: chapter_pending_givens)
459
+ def self.build(name, chapter_named_givens: {}, chapter_pending_givens: [],
460
+ chapter_entity_named_givens: {}, chapter_entity_pending_givens: [], &block)
461
+ builder = new(name, chapter_named_givens: chapter_named_givens, chapter_pending_givens: chapter_pending_givens,
462
+ chapter_entity_named_givens: chapter_entity_named_givens,
463
+ chapter_entity_pending_givens: chapter_entity_pending_givens)
450
464
  builder.instance_eval(&block) if block
451
465
  builder.build
452
466
  end
@@ -496,6 +510,9 @@ module Hecks
496
510
  owner_named_givens: @entity_named_givens,
497
511
  identity_name_prefix: "#{Naming.demodulise(@name)}#{Naming.demodulise(name)}",
498
512
  identity_value_object_installer: ->(value_object) { @value_objects << value_object },
513
+ aggregate_name: @name,
514
+ chapter_entity_named_givens: @chapter_entity_named_givens,
515
+ chapter_entity_pending_givens: @chapter_entity_pending_givens,
499
516
  &block)
500
517
  end
501
518
 
@@ -609,7 +626,17 @@ module Hecks
609
626
  # keys: a default that is a Hash is left to `Value.for_attribute`, which
610
627
  # is where a wrong FIELD belongs.
611
628
  def seal_defaults
612
- shapes = @value_objects.map { |shape| shape.hecks_name.to_s }
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 }
613
640
 
614
641
  attributes.each do |attribute|
615
642
  next if attribute.default.nil? || attribute.default.is_a?(Hash)
@@ -683,7 +710,10 @@ module Hecks
683
710
  # #step_delegate_to_entity`, which refuses a real one that
684
711
  # names no such entity or command). Sealing THIS check
685
712
  # against it would refuse every delegating command outright.
686
- next if mutation.op == :delegate
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)
687
717
  next if known.include?(mutation.target.to_sym)
688
718
 
689
719
  raise Malformed,
@@ -694,6 +724,88 @@ module Hecks
694
724
  end
695
725
  end
696
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
+
697
809
  # A query must ask about a field the aggregate actually HAS — the same
698
810
  # seal `then_set` gets, closing the same silence: a where over a field
699
811
  # nothing declares matches nothing and refuses nothing, forever, on
@@ -62,6 +62,8 @@ module Hecks
62
62
  admits: nil, one_of: nil)
63
63
  # moved to the language: FieldName invariant, on Root.Attribute
64
64
 
65
+ refuse_duplicate_attribute!(name)
66
+
65
67
  if type.equal?(UNSET)
66
68
  raise Malformed, "#{name} declares no type — attribute :#{name}, SomeType is required, " \
67
69
  "there is no default"
@@ -156,6 +158,7 @@ module Hecks
156
158
  private
157
159
 
158
160
  def relationship_attribute(target, kind, name, optional: false, list: false)
161
+ refuse_duplicate_attribute!(name)
159
162
  attributes << Attribute.new(
160
163
  name: name,
161
164
  type: Reference.new(target),
@@ -183,6 +186,24 @@ module Hecks
183
186
  "with the type-position one_of(...) instead"
184
187
  end
185
188
 
189
+ # A NAME DECLARED TWICE ON THE SAME OWNER IS TWO ATTRIBUTES SHARING
190
+ # ONE NAME, and nothing downstream disambiguates them — every
191
+ # reader that walks `attributes` looking for one by name
192
+ # (`seal_mutation_targets`, `seal_query_field`, `projects`'s own
193
+ # local check, `Instance#[]`, ...) uses `Array#find`/`any?`, which
194
+ # silently answers whichever declaration happens to come first and
195
+ # discards the second. Used to boot clean and stay that way : both
196
+ # declarations survived into the IR, one of them permanently
197
+ # unreachable by name. Refused HERE, at the one place every owner
198
+ # (Aggregate/Entity/Command/Query/PortOperation/ValueObject, each
199
+ # `include AttributeCollector`) mints an attribute through, rather
200
+ # than taught to each of those readers individually.
201
+ def refuse_duplicate_attribute!(name)
202
+ return unless attributes.any? { |attribute| attribute.name == name }
203
+
204
+ raise Malformed, "#{name} is declared twice — an attribute name is declared once, not twice"
205
+ end
206
+
186
207
  # A pattern is refused AT DECLARATION, not when a value first meets it :
187
208
  # a regex whose meaning depends on which engine reads it is a defect in
188
209
  # the bluebook, and a bluebook that loads is one whose patterns carry
@@ -28,6 +28,13 @@ module Hecks
28
28
  # what queues here and `#resolve_pending_chapter_givens!`,
29
29
  # below, for where it drains.
30
30
  @chapter_pending_givens = []
31
+ # ONE LEVEL WIDER STILL — the CHAPTER-WIDE, ENTITY-SCOPED pool
32
+ # (the piece analogue of `@chapter_named_givens`, above). See
33
+ # `EntityBuilder#given_impl`'s own comment for what this
34
+ # closes; `docs/implemented/resolution-rules/
35
+ # chapter-entity-given.md` for the full algorithm.
36
+ @chapter_entity_named_givens = {}
37
+ @chapter_entity_pending_givens = []
31
38
  end
32
39
 
33
40
  # Chapter metadata belongs to the composed folder, not whichever file
@@ -85,8 +92,10 @@ module Hecks
85
92
  # chapter's own top-level shape is written with it), so also
86
93
  # named in GenericDispatch::BOOTSTRAP_CALLS_FALLBACK.
87
94
  def aggregate_impl(name, &block)
88
- @aggregates << AggregateBuilder.build(name, chapter_named_givens: @chapter_named_givens,
89
- chapter_pending_givens: @chapter_pending_givens, &block)
95
+ @aggregates << AggregateBuilder.build(name, chapter_named_givens: @chapter_named_givens,
96
+ chapter_pending_givens: @chapter_pending_givens,
97
+ chapter_entity_named_givens: @chapter_entity_named_givens,
98
+ chapter_entity_pending_givens: @chapter_entity_pending_givens, &block)
90
99
  end
91
100
 
92
101
  # `read_model` is the word (ADR 0025 reverts `report` — the IR
@@ -143,6 +152,7 @@ module Hecks
143
152
  # `validate_assembled!` there — nothing downstream should ever
144
153
  # read an unresolved placeholder's fields.
145
154
  resolve_pending_chapter_givens! unless MetaValidator.deferring?
155
+ resolve_pending_chapter_entity_givens! unless MetaValidator.deferring?
146
156
 
147
157
  # A CHAPTER MAY BE SPLIT ACROSS FILES (see `self.build`'s own
148
158
  # comment). Every check below needs the WHOLE chapter present —
@@ -225,6 +235,47 @@ module Hecks
225
235
  end
226
236
  private :resolve_pending_chapter_given
227
237
 
238
+ # THE ENTITY-SCOPED ANALOGUE, one level down — see
239
+ # `#resolve_pending_chapter_givens!`'s own comment; identical
240
+ # shape, resolved against `@chapter_entity_named_givens` instead.
241
+ def resolve_pending_chapter_entity_givens!
242
+ @chapter_entity_pending_givens.each do |entry|
243
+ resolved = resolve_pending_chapter_entity_given(entry)
244
+ entry[:placeholder].description = resolved.description
245
+ entry[:placeholder].canonical = resolved.canonical
246
+ entry[:placeholder].predicate = resolved.predicate
247
+ end
248
+ @chapter_entity_pending_givens.clear
249
+ end
250
+
251
+ def resolve_pending_chapter_entity_given(entry)
252
+ description = entry[:description]
253
+ candidates = RuleReference.resolve_owner_keyed(@chapter_entity_named_givens, description)
254
+
255
+ if entry[:declared_by]
256
+ candidates[entry[:declared_by]] ||
257
+ raise(Malformed,
258
+ "#{entry[:entity]}'s given #{description.inspect} names no precondition " \
259
+ "#{entry[:declared_by]} declares in this chapter — #{entry[:declared_by]} " \
260
+ "either hasn't declared #{description.inspect}, or declared_by: named the " \
261
+ "wrong piece")
262
+ elsif candidates.size == 1
263
+ candidates.values.first
264
+ elsif candidates.empty?
265
+ raise(Malformed,
266
+ "#{entry[:entity]}'s given #{description.inspect} names no precondition " \
267
+ "any piece in this chapter ever declares — declare it once with a block " \
268
+ "(some piece's own given(#{description.inspect}) { ... })")
269
+ else
270
+ raise(Malformed,
271
+ "#{entry[:entity]}'s given #{description.inspect} is ambiguous across the " \
272
+ "chapter's own pieces — #{candidates.keys.join(', ')} each declare a DIFFERENT " \
273
+ "predicate under this same description; name which one with declared_by: (e.g. " \
274
+ "given(#{description.inspect}, declared_by: #{candidates.keys.first.inspect}))")
275
+ end
276
+ end
277
+ private :resolve_pending_chapter_entity_given
278
+
228
279
  # EVERY WHOLE-CHAPTER CHECK, IN ONE PLACE — the battery `#build`
229
280
  # used to run inline, now a pure function of an assembled
230
281
  # `Bluebook::Chapter` so `MetaValidator.judge_deferred!` can run
@@ -862,6 +913,24 @@ module Hecks
862
913
  # nothing further to check once it matches by name.
863
914
  return if remote_attribute.nil? && target.lifecycle&.field.to_s == plan.tail
864
915
 
916
+ # A PROJECTION MAY CHAIN THROUGH ANOTHER PROJECTION (S12, ADR
917
+ # 0025's own boundary rule, followed through) — `target`'s
918
+ # OWN projected fields live in `projected_fields`, a
919
+ # separate list from `attributes`, so a match there is
920
+ # invisible to the check above even though it names a real,
921
+ # always-current, stored field. `Transfer.projects
922
+ # :source_customer_status, from: :"source.customer_status"`
923
+ # reads Account's own already-projected `customer_status`
924
+ # this way — Account is one hop from Customer, Transfer is
925
+ # one hop from Account, and neither aggregate needs to know
926
+ # about the other's target two hops away. A projected
927
+ # field's remote value is always a scalar by construction
928
+ # (`RebuildSweep.remote_value` never copies a reference, a
929
+ # value object, or a list), so nothing further to check once
930
+ # it matches by name — same reasoning the lifecycle
931
+ # fallback just above already applies.
932
+ return if remote_attribute.nil? && target.projected_fields.any? { |f| f.name.to_s == plan.tail }
933
+
865
934
  unless remote_attribute
866
935
  raise Malformed,
867
936
  "#{aggregate.hecks_name}.projects :#{field.name} reads #{target.hecks_name}'s own " \
@@ -297,7 +297,7 @@ module Hecks
297
297
  end
298
298
 
299
299
  op, source = named.first
300
- @mutations << Mutation.new(target: target.to_sym, op: op, source: source)
300
+ @mutations << Mutation.new(target: target.to_sym, op: op, source: normalize_append_source(op, source))
301
301
  end
302
302
 
303
303
  # LEGACY UNDER SHADOW-PARSING (S0a's own bridge) — frozen era text
@@ -319,8 +319,23 @@ module Hecks
319
319
  # enforces it. This is the first rule to move ACROSS rather than be
320
320
  # duplicated : delete the declaration and an unnamed event is accepted,
321
321
  # which is what makes the meta-domain load-bearing rather than decorative.
322
+ #
323
+ # BARE CONSTANT ACCEPTED (ADR 0025, S6 — "events first-class"),
324
+ # `emits Account::AccountFrozen`, resolved through `ConstShim` the
325
+ # same way `trigger`/`dispatch` already resolve a command
326
+ # reference (`Naming.event_ref`, that method's own header). NOT
327
+ # yet a REQUIRED spelling, deliberately, unlike `trigger`/
328
+ # `dispatch`'s own quoted-text refusal: those were safe to refuse
329
+ # only because command references are already 100% migrated
330
+ # across the live corpus (verified 2026-08-27) — `emits`/`on`
331
+ # are not, so refusing the quoted form here would break every
332
+ # live `.bluebook` site this pass didn't touch, not just frozen
333
+ # era text `shadow_parse` exists to keep readable. Both forms
334
+ # are accepted in live source until a full corpus migration
335
+ # lands and the same refusal this file's `reference_to`/
336
+ # `trigger_impl` already carry can be added here safely.
322
337
  def emits(event_name)
323
- @emits << event_name.to_s
338
+ @emits << Naming.event_ref(event_name)
324
339
  end
325
340
 
326
341
  # THE RECORD'S OWN VALUE AS A MUTATION SOURCE — `sets :positions,
@@ -412,6 +427,57 @@ module Hecks
412
427
  @mutations << Mutation.new(target: target.to_s, op: :delegate, source: with)
413
428
  end
414
429
 
430
+ # A COMMAND DECLARING WHAT PAST FACT IT AMENDS — the append-only
431
+ # answer to "what if this record's history turns out to have been
432
+ # wrong": never rewrite the original event (the log stays exactly
433
+ # what it was), always append a NEW fact on top. `event` names the
434
+ # event this command corrects; `as:` optionally binds the located
435
+ # instance for a `given`/`ensures` to reference, the same shape
436
+ # `ensures`'s own `old` binding already has; `reason:` is not
437
+ # descriptive-only the way `goal` is — it is carried as data, the
438
+ # one thing an audit trail actually needs ("we corrected this, and
439
+ # here is why"), refused when blank the same way a `given`'s own
440
+ # description is required to say something.
441
+ #
442
+ # STORED AS A MUTATION, not a new Command field — see the
443
+ # KeywordSeed row's own comment (command.bluebook) for why: this
444
+ # is the exact same choice `delegates_to` already made, for the
445
+ # exact same reason. Rides the SAME multi-binding wire shape
446
+ # `append`/`delegate` use — `as:`/`reason:`/`reverses:` assembled
447
+ # by hand into one `source` hash, the way `sets_impl` assembles up
448
+ # to seven kwargs into one `named` hash above.
449
+ #
450
+ # `reverses: true` NAMES an intent to auto-derive the corrective
451
+ # `sets` from the original event's own mutations, rather than the
452
+ # author writing it — see `AggregateBuilder#seal_correction_targets`,
453
+ # where that derivation actually happens (it needs every sibling
454
+ # command in the aggregate already known, which this builder alone
455
+ # cannot see). MUTUALLY EXCLUSIVE with an explicit `sets` on the
456
+ # same command — two ways of saying the same thing is exactly the
457
+ # redundancy `sets`'s own omittable-`to:` rule refuses elsewhere.
458
+ #
459
+ # `as:` IS ALWAYS STORED AS TEXT, never left a bare Symbol —
460
+ # `Mutation#classified_source`/`#appended_fields` (Behaviour::
461
+ # Mutation) classify any bare Symbol field as `kind: "argument"`,
462
+ # meaning "resolve this against one of THIS command's own declared
463
+ # attributes at dispatch time" (append/delegate's own meaning for a
464
+ # Symbol). `as:` names no such thing — it is a plain label, not yet
465
+ # wired into the expression evaluator (a future round's work, once
466
+ # a real runtime consumer exists) — so coercing it to a String here
467
+ # keeps it out of that machinery entirely rather than silently
468
+ # miscategorised as an unresolvable argument reference.
469
+ def corrects_impl(event, as: nil, reason: nil, reverses: false)
470
+ if reason.to_s.strip.empty?
471
+ raise Malformed,
472
+ "#{@name}'s corrects #{event.inspect} names no reason — a correction " \
473
+ "is carried as data (an audit trail needs to say WHY), the same way a " \
474
+ "given's own description must say something"
475
+ end
476
+
477
+ @mutations << Mutation.new(target: event.to_s, op: :corrects,
478
+ source: { as: as&.to_s, reason: reason.to_s, reverses: reverses })
479
+ end
480
+
415
481
  def build
416
482
  resolve_implicit_attributes!
417
483
 
@@ -488,6 +554,59 @@ module Hecks
488
554
  end
489
555
  refuse_unknown_state_sources!(mutation)
490
556
  end
557
+ # A SECOND, SEPARATE pass — not folded into the loop above — so
558
+ # every mutation's own self-referential import (resolve_bare_set!)
559
+ # has already landed before any mutation's SOURCE is checked
560
+ # against the final `attributes` list. `sets :a, to: :b` declared
561
+ # before `sets :b` (bare, importing :b from the owner) is real
562
+ # and legal; checking inline, mutation by mutation, would refuse
563
+ # it for an ordering accident this language has never required
564
+ # authors to avoid. rubocop's Style/CombinableLoops can't see
565
+ # that dependency — it only sees two `@mutations.each` calls
566
+ # back to back — so it's disabled here, not satisfied.
567
+ # rubocop:disable-next Style/CombinableLoops
568
+ @mutations.each { |mutation| refuse_unknown_argument_sources!(mutation) }
569
+ end
570
+
571
+ # THE ONES `resolve_source` (CommandRules::Arithmetic) ONLY EVER
572
+ # READS FROM `args` — never a fallback to the record's own current
573
+ # state the way `append`'s own per-field resolution legitimately
574
+ # can (`MutationApplier#resolve_append_source`'s own `instance
575
+ # [source]` fallback, a real, intentional second meaning this
576
+ # check must not foreclose). For exactly these five ops, a bare
577
+ # Symbol source has exactly one legitimate reading — the name of
578
+ # a declared argument — so anything else is unreachable at
579
+ # runtime, not merely optional: no caller can ever supply a value
580
+ # under a name the command never declared (ArgumentGate's own
581
+ # `refuse_unknown_arguments` already refuses that), so the source
582
+ # resolves to nil FOREVER, indistinguishable from a legitimately
583
+ # absent OPTIONAL argument until this check existed to tell them
584
+ # apart. Mirrors `AggregateBuilder#seal_query_argument`'s
585
+ # identical shape for a query's own where-clause argument —
586
+ # same mistake, one construct over.
587
+ CHECKED_SYMBOL_SOURCE_OPS = %i[set increment decrement multiply remove].freeze
588
+ private_constant :CHECKED_SYMBOL_SOURCE_OPS
589
+
590
+ def refuse_unknown_argument_sources!(mutation)
591
+ return unless CHECKED_SYMBOL_SOURCE_OPS.include?(mutation.op)
592
+ return unless mutation.source.is_a?(Symbol)
593
+ # The bare self-referential shape (`sets :field` alone, `source
594
+ # == target`) is `resolve_bare_set!`'s own territory, not this
595
+ # check's — when neither the command nor the owner declares
596
+ # that name, the MORE SPECIFIC, pre-existing refusal one level
597
+ # up (`AggregateBuilder#seal_mutation_targets`, checking the
598
+ # mutation's TARGET against the aggregate's own fields) is the
599
+ # one that should fire, naming the field as a target problem,
600
+ # not — confusingly — as a source problem this check would
601
+ # otherwise misreport it as.
602
+ return if mutation.source.to_s == mutation.target.to_s
603
+ return if attributes.any? { |attr| attr.name == mutation.source }
604
+
605
+ raise Malformed,
606
+ "#{@name}'s sets :#{mutation.target} resolves :#{mutation.source} from its " \
607
+ "arguments, but #{@name} declares no #{mutation.source} attribute — an " \
608
+ "argument that does not exist resolves to nil, always, never what the " \
609
+ "caller actually sent"
491
610
  end
492
611
 
493
612
  # `state(:name)` names one of the OWNER'S OWN fields — a snapshot
@@ -618,7 +737,29 @@ module Hecks
618
737
  end
619
738
 
620
739
  op, source = named.first
621
- @mutations << Mutation.new(target: target.to_sym, op: op, source: source)
740
+ @mutations << Mutation.new(target: target.to_sym, op: op, source: normalize_append_source(op, source))
741
+ end
742
+
743
+ # `append:` NORMALLY binds several fields at once (`append: {
744
+ # name: :name, amount: :amount }`) — `Mutation#appended_fields`/
745
+ # `MutationApplier#appended`/the meta-validator Judge's own
746
+ # `mutation_rows` all read `mutation.source` as a Hash
747
+ # unconditionally. A BARE value (`append: :single_field`, or any
748
+ # non-Hash literal) is the one-field shorthand: exactly what an
749
+ # explicit `append: { value: :single_field }` would have meant,
750
+ # named the same way a single-field value object's own implicit
751
+ # member already is (`MutationApplier#appended`'s own `:value`
752
+ # scalar-unwrap). Without this, that shorthand built a Mutation
753
+ # whose `source` was a bare Symbol, which crashed with a raw
754
+ # `NoMethodError` on `#transform_values` the moment anything
755
+ # downstream read it — at dispatch (`MutationApplier#appended`),
756
+ # at IR emission (`Mutation#appended_fields`), and in the
757
+ # meta-validator's own Judge (`Readings#mutation_rows`). #138.
758
+ def normalize_append_source(op, source)
759
+ return source unless op == :append
760
+ return source if source.is_a?(::Hash)
761
+
762
+ { value: source }
622
763
  end
623
764
  end
624
765
  end
@@ -7,10 +7,28 @@ module Hecks
7
7
 
8
8
  include WordGate
9
9
 
10
- def initialize(name, owner: nil)
11
- @name = name
12
- @owner = owner
13
- @operations = []
10
+ # `legacy_bare_port:` ONLY `Hecks.port`'s own top-level method
11
+ # (lib/hecks.rb) passes `true`. `PortBuilder#build` never refused
12
+ # an empty build (no verb, no signal, nothing) — `Port.new(verb:
13
+ # nil, signal: :reply)` is a real, allowed shape dsl_spec.rb's own
14
+ # "a port" tests rely on (`signal`-only, no `verb` at all). The
15
+ # AGGREGATE-scoped (`BindingProxy#port`) and hecksagon-ROOT
16
+ # (`HecksagonBuilder#port_impl`) callers both reach this SAME
17
+ # class with `owner: nil` too when they're building the bare-verb
18
+ # shape (`port_impl`'s own root-level port can be EITHER shape,
19
+ # decided only after `build` returns) — so `owner.nil?` cannot be
20
+ # the discriminator between "old Hecks.port semantics" and "real
21
+ # DomainPort semantics"; those two callers correctly want the
22
+ # stricter "declares no verb and no operations" refusal `build`
23
+ # already raises below, unchanged. Only the literal top-level
24
+ # `.port` file caller wants the older, looser rule.
25
+ def initialize(name, owner: nil, legacy_bare_port: false)
26
+ @name = name
27
+ @owner = owner
28
+ @operations = []
29
+ @signal = :reply
30
+ @answers = []
31
+ @legacy_bare_port = legacy_bare_port
14
32
  end
15
33
 
16
34
  # WHAT THE OUTSIDE TELLS US — an external fact arriving, translated
@@ -58,18 +76,42 @@ module Hecks
58
76
  # coerce-and-assign with nothing else, now executed by
59
77
  # `GenericDispatch`.
60
78
 
79
+ # `Hecks.port "x" do verb "y"; signal :effect end`'s own two words,
80
+ # reachable here too — a bare-verb `DomainPortBuilder.build` falls
81
+ # back to the SAME `Port` object `PortBuilder` produces (`build`,
82
+ # below), so any `.port` file can migrate to being parsed by this
83
+ # builder with zero change to its own text, or to any caller that
84
+ # reads `.verb`/`.signal` off the `Port` it gets back. Ordinary
85
+ # `def`s, exactly like `PortBuilder`'s own — `WordGate`'s own
86
+ # header is explicit that a word answered this way never reaches
87
+ # its `method_missing`, so no new self-hosted grammar row is
88
+ # needed for either word under this context.
89
+ def verb(value) = @verb = value.to_s
90
+ def signal(value) = @signal = value.to_sym
91
+
92
+ # THE METHOD CONTRACT — `PortBuilder#answers`'s own twin, added
93
+ # here after the fact: a `.port` file migrated to parse through
94
+ # this builder (the repoint `lib/hecks.rb#port`'s own comment
95
+ # describes) can still declare one (`extraction.port`'s own
96
+ # `answers :canonical`, real, live corpus text) — this builder's
97
+ # bare-verb fallback needs to carry it through to the same `Port`
98
+ # object `PortBuilder` itself would have built, or the migration
99
+ # would silently drop a method-contract check for any `.port`
100
+ # file that uses this word.
101
+ def answers(name) = @answers << name.to_sym
102
+
61
103
  def build
62
104
  raise Malformed, "#{@name} declares both a verb and operations — a port is one or the other, not both" if @verb && !@operations.empty?
63
105
 
64
- return Port.new(name: @name, verb: @verb, signal: :reply) if @verb
106
+ return MetaValidator.call_port(Port.new(name: @name, verb: @verb, signal: @signal, answers: @answers)) if @verb || (@legacy_bare_port && @operations.empty?)
65
107
 
66
108
  raise Malformed, "#{@name} declares no verb and no operations" if @operations.empty?
67
109
 
68
110
  DomainPort.new(name: @name, operations: @operations)
69
111
  end
70
112
 
71
- def self.build(name, owner: nil, &block)
72
- builder = new(name, owner: owner)
113
+ def self.build(name, owner: nil, legacy_bare_port: false, &block)
114
+ builder = new(name, owner: owner, legacy_bare_port: legacy_bare_port)
73
115
  builder.instance_eval(&block) if block
74
116
  builder.build
75
117
  end