hecks 1.0.2 → 1.2.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 (247) 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 +100 -0
  15. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  16. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
  17. data/lib/hecks/adapters/driven/postgres.rb +45 -31
  18. data/lib/hecks/adapters/driven/prism.rb +11 -0
  19. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  20. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  21. data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
  22. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  23. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  24. data/lib/hecks/adapters/driven.rb +4 -0
  25. data/lib/hecks/adapters.rb +4 -0
  26. data/lib/hecks/behaviors/dsl.rb +7 -0
  27. data/lib/hecks/behaviors/expectations.rb +32 -4
  28. data/lib/hecks/behaviors/rspec.rb +2 -0
  29. data/lib/hecks/behaviors/runner.rb +4 -0
  30. data/lib/hecks/bluebook/aggregate.rb +6 -5
  31. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  32. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  33. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  34. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  35. data/lib/hecks/bluebook/assembly/marks.rb +15 -4
  36. data/lib/hecks/bluebook/assembly.rb +1 -3
  37. data/lib/hecks/bluebook/attribute.rb +6 -0
  38. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  39. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  40. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  41. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  42. data/lib/hecks/bluebook/command.rb +6 -5
  43. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  44. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  45. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
  46. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  47. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  48. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  49. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  50. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  51. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  52. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  53. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  54. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  55. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  56. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  57. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  58. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  59. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  60. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  61. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  62. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  63. data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
  64. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  65. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  66. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  69. data/lib/hecks/bluebook/entity.rb +5 -3
  70. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  71. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  72. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  73. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  74. data/lib/hecks/bluebook/expression/projection.json +12 -0
  75. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  76. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  77. data/lib/hecks/bluebook/expression.rb +1 -0
  78. data/lib/hecks/bluebook/hexagon.rb +10 -0
  79. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  80. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  81. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  82. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  83. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  84. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  85. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  86. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  87. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  88. data/lib/hecks/bluebook/model_check.rb +119 -82
  89. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  90. data/lib/hecks/bluebook/policy.rb +2 -1
  91. data/lib/hecks/bluebook/process_manager.rb +6 -0
  92. data/lib/hecks/bluebook/project_register.rb +45 -25
  93. data/lib/hecks/bluebook/query.rb +4 -0
  94. data/lib/hecks/bluebook/read_model.rb +2 -2
  95. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  96. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  97. data/lib/hecks/bluebook/translation.rb +3 -0
  98. data/lib/hecks/bluebook/value_object.rb +2 -2
  99. data/lib/hecks/codemod.rb +13 -0
  100. data/lib/hecks/doc/reference.rb +17 -11
  101. data/lib/hecks/facade/cli_runner.rb +2 -2
  102. data/lib/hecks/facade/command_request.rb +11 -11
  103. data/lib/hecks/facade/handle.rb +3 -3
  104. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  105. data/lib/hecks/facade.rb +4 -0
  106. data/lib/hecks/forms/app.rb +10 -6
  107. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  108. data/lib/hecks/forms/field_renderer.rb +14 -9
  109. data/lib/hecks/forms/field_shape.rb +7 -2
  110. data/lib/hecks/forms/html.rb +5 -2
  111. data/lib/hecks/forms/index_renderer.rb +1 -1
  112. data/lib/hecks/forms/page.rb +7 -3
  113. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  114. data/lib/hecks/forms/record_renderer.rb +22 -16
  115. data/lib/hecks/forms.rb +4 -0
  116. data/lib/hecks/fqn.rb +21 -9
  117. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  118. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  119. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  120. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  121. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  122. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  123. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  124. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  125. data/lib/hecks/fuzzing/properties.rb +41 -1065
  126. data/lib/hecks/fuzzing/replay.rb +60 -16
  127. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  128. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  129. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  130. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  131. data/lib/hecks/grammar/evolve.rb +34 -10
  132. data/lib/hecks/grammar/expression_operators.json +124 -0
  133. data/lib/hecks/grammar.rb +15 -5
  134. data/lib/hecks/ir.rb +9 -0
  135. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  136. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  138. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  140. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  141. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  142. data/lib/hecks/literal.rb +18 -8
  143. data/lib/hecks/naming.rb +6 -3
  144. data/lib/hecks/ports/access_control.rb +1 -1
  145. data/lib/hecks/ports/agent.rb +5 -3
  146. data/lib/hecks/ports/authentication.rb +3 -2
  147. data/lib/hecks/ports/authorization.rb +1 -1
  148. data/lib/hecks/ports/clock.rb +1 -1
  149. data/lib/hecks/ports/extraction.rb +5 -1
  150. data/lib/hecks/ports/identity_assignment.rb +1 -1
  151. data/lib/hecks/ports/identity_generation.rb +1 -1
  152. data/lib/hecks/ports/identity_resolution.rb +1 -1
  153. data/lib/hecks/ports/loading.rb +4 -1
  154. data/lib/hecks/ports/persistence/append_only.rb +51 -4
  155. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  156. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  157. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  158. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  159. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  169. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  170. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  172. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  173. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  174. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  175. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  176. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  177. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  178. data/lib/hecks/ports/persistence.rb +7 -3
  179. data/lib/hecks/ports/projection.rb +13 -5
  180. data/lib/hecks/ports/query/in_memory.rb +8 -2
  181. data/lib/hecks/ports/query.rb +1 -3
  182. data/lib/hecks/ports.rb +2 -0
  183. data/lib/hecks/projections/diagrams.rb +11 -3
  184. data/lib/hecks/projections/glossary.rb +374 -0
  185. data/lib/hecks/projections/ir.rb +2 -0
  186. data/lib/hecks/projections/model/deviations.rb +2 -1
  187. data/lib/hecks/projections/model.rb +15 -10
  188. data/lib/hecks/projections/vocabulary.rb +1 -3
  189. data/lib/hecks/projections.rb +1 -0
  190. data/lib/hecks/projector/cli_projector.rb +78 -42
  191. data/lib/hecks/projector/docs_projector.rb +21 -12
  192. data/lib/hecks/projector/exporter.rb +34 -1
  193. data/lib/hecks/projector/narrate_projector.rb +83 -25
  194. data/lib/hecks/projector.rb +2 -2
  195. data/lib/hecks/query_ir.rb +60 -36
  196. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  197. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  198. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  199. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  200. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  201. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  202. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  203. data/lib/hecks/query_specification/common/options.rb +17 -5
  204. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  205. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  206. data/lib/hecks/query_specification/field_path.rb +4 -4
  207. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  208. data/lib/hecks/router/namespace_installer.rb +22 -10
  209. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  210. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  211. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  212. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  213. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  214. data/lib/hecks/runtime/dispatcher.rb +41 -7
  215. data/lib/hecks/runtime/entity_element.rb +25 -6
  216. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  217. data/lib/hecks/runtime/errors.rb +28 -2
  218. data/lib/hecks/runtime/instance.rb +9 -3
  219. data/lib/hecks/runtime/interpreting.rb +45 -9
  220. data/lib/hecks/runtime/loader.rb +21 -0
  221. data/lib/hecks/runtime/outbox.rb +365 -0
  222. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  223. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  224. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  225. data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
  226. data/lib/hecks/runtime/reference_hop.rb +1 -1
  227. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  228. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  229. data/lib/hecks/runtime/registry/verification.rb +39 -2
  230. data/lib/hecks/runtime/registry.rb +21 -11
  231. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  232. data/lib/hecks/runtime/routing.rb +52 -39
  233. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  234. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  235. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  236. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  237. data/lib/hecks/runtime/value/coercion.rb +271 -49
  238. data/lib/hecks/runtime/value.rb +1 -1
  239. data/lib/hecks/runtime.rb +7 -2
  240. data/lib/hecks/storehouse.rb +15 -8
  241. data/lib/hecks/version.rb +1 -1
  242. data/lib/hecks/vocabulary.rb +1 -0
  243. data/lib/hecks.rb +17 -13
  244. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  245. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  246. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  247. metadata +16 -2
@@ -0,0 +1,374 @@
1
+ require_relative "../projector"
2
+
3
+ module Hecks
4
+ module Projections
5
+ # A CHAPTER, PROJECTED AS ITS UBIQUITOUS LANGUAGE — one glossary
6
+ # entry per term the business actually uses: the Domain itself, and
7
+ # every Aggregate, Entity, Value Object, Command (a verb), Query (a
8
+ # question), Read Model, Event, Role, Lifecycle, Saga, and Policy it
9
+ # declares — every BUSINESS-domain construct the language's own §30
10
+ # reference distinguishes. Deliberately not the deployment/wiring
11
+ # half (World, Hecksagon, Port, Adapter, Translation): Evans'
12
+ # Ubiquitous Language is the business model, not the plumbing that
13
+ # runs it.
14
+ #
15
+ # GROUPED UNDER THE AGGREGATE EACH TERM BELONGS TO — a reader thinks
16
+ # "what does Account mean" before they think "what starts with A",
17
+ # so a term's home is the aggregate that declares it (an Entity's
18
+ # own Commands/Queries/Lifecycle nest under ITS aggregate, not
19
+ # listed as their own peer); an Event or a Policy that reacts to one
20
+ # nests under the aggregate whose command actually raises it — the
21
+ # same "who does this" a reader would ask by hand. Only what
22
+ # genuinely belongs to no single aggregate (a Role, issued across
23
+ # several; a Read Model, joined across several) gets its own
24
+ # trailing section. Within a section, still one short sentence per
25
+ # term, dictionary-plain — this is not `DocsProjector` re-sorted
26
+ # (verbs, refusals, wire shapes, walked for a caller); it is what
27
+ # each term MEANS, walked for a domain expert.
28
+ #
29
+ # WHAT IT DELIBERATELY DOES NOT DO: invent, same discipline as
30
+ # `DocsProjector`. A term with no declared prose (most value objects;
31
+ # every event and role, which the language only ever spells as bare
32
+ # strings — a command's `emits:`/`role:`) gets a definition DERIVED
33
+ # from the graph around it — what it's shaped like, what emits it,
34
+ # who issues it — never a restated name standing in for a sentence
35
+ # nobody wrote.
36
+ #
37
+ # Projector.call(:glossary, bluebook: <the Bluebook chapter>)
38
+ module Glossary
39
+ extend Projector::Target
40
+
41
+ projects_as :glossary
42
+
43
+ Entry = Struct.new(:term, :kind, :definition, :within, :section, keyword_init: true)
44
+
45
+ # DISPLAY ORDER WITHIN A SECTION — the aggregate's own row first
46
+ # (what it IS), then the shapes it can be in and hold, then what
47
+ # you can ask of it, then what happens on its own.
48
+ KIND_RANK = {
49
+ "Aggregate" => 0, "Lifecycle" => 1, "Entity" => 2, "Value Object" => 3,
50
+ "Command" => 4, "Query" => 5, "Event" => 6, "Policy" => 7, "Saga" => 8
51
+ }.freeze
52
+
53
+ ROLES_SECTION = "Roles".freeze
54
+ READ_MODELS_SECTION = "Read Models".freeze
55
+
56
+ module_function
57
+
58
+ def call(bluebook:, options: {}) = render(bluebook)
59
+
60
+ # ── gathering ─────────────────────────────────────────────────────
61
+
62
+ # AN ENTITY CAN CARRY ITS OWN COMMANDS, QUERIES, AND VALUE OBJECTS
63
+ # TOO — walked the same one level down `DocsProjector` and
64
+ # `Projections::Diagrams` already walk it, so a domain's entity
65
+ # gaining any of these needs no change here either.
66
+ def holders(bluebook)
67
+ bluebook.aggregates.flat_map { |aggregate| [aggregate, *aggregate.entities] }
68
+ end
69
+
70
+ # EVERY HOLDER'S OWN AGGREGATE, ONE HOP OR ZERO — an aggregate
71
+ # maps to itself, an entity to whichever aggregate declared it.
72
+ # This is the single fact the whole grouping is built on: a
73
+ # Command/Query/Lifecycle's `within` already names its holder, so
74
+ # looking that name up here is enough to find its SECTION even
75
+ # when the holder is an entity two levels down from the chapter.
76
+ def holder_aggregate(bluebook)
77
+ bluebook.aggregates.each_with_object({}) do |aggregate, map|
78
+ map[aggregate.hecks_name] = aggregate.hecks_name
79
+ aggregate.entities.each { |entity| map[entity.hecks_name] = aggregate.hecks_name }
80
+ end
81
+ end
82
+
83
+ # EVERY EVENT'S OWN AGGREGATE — an event is never declared, only
84
+ # emitted, so its home is whichever aggregate the FIRST command
85
+ # that raises it belongs to. A policy or saga reacting to that
86
+ # event inherits the same home, the same way a reader tracing the
87
+ # reaction by hand would: "what raises this, and whose is that."
88
+ def event_aggregate(bluebook, holder_aggregate)
89
+ map = {}
90
+ holders(bluebook).each do |holder|
91
+ home = holder_aggregate[holder.hecks_name]
92
+ holder.commands.each { |command| command.emits.each { |event| map[event] ||= home } }
93
+ end
94
+ map
95
+ end
96
+
97
+ def entries(bluebook)
98
+ holder_agg = holder_aggregate(bluebook)
99
+ event_agg = event_aggregate(bluebook, holder_agg)
100
+
101
+ entries = []
102
+ entries += domain_entries(bluebook)
103
+ entries += aggregate_entries(bluebook)
104
+ entries += entity_entries(bluebook, holder_agg)
105
+ entries += value_object_entries(bluebook, holder_agg)
106
+ entries += command_entries(bluebook, holder_agg)
107
+ entries += query_entries(bluebook, holder_agg)
108
+ entries += read_model_entries(bluebook)
109
+ entries += event_entries(bluebook, event_agg)
110
+ entries += role_entries(bluebook)
111
+ entries += lifecycle_entries(bluebook, holder_agg)
112
+ entries += saga_entries(bluebook, event_agg)
113
+ entries += policy_entries(bluebook, event_agg)
114
+ entries
115
+ end
116
+
117
+ # THE CHAPTER ITSELF, AS A TERM — the one construct every other
118
+ # entry here lives inside (`Hecks.bluebook "Banking" do ... end`),
119
+ # and the one place `vision` — the single sentence written for a
120
+ # reader who doesn't know the domain yet — actually lives. Given
121
+ # no `section` (rendered once, at the very top, above every
122
+ # aggregate) since it is the one term that belongs to no aggregate
123
+ # by being the thing that holds them all.
124
+ def domain_entries(bluebook)
125
+ [Entry.new(term: bluebook.name, kind: "Domain", definition: bluebook.vision)]
126
+ end
127
+
128
+ def aggregate_entries(bluebook)
129
+ bluebook.aggregates.map do |aggregate|
130
+ Entry.new(term: aggregate.hecks_name, kind: "Aggregate", definition: aggregate.description,
131
+ section: aggregate.hecks_name)
132
+ end
133
+ end
134
+
135
+ def entity_entries(bluebook, holder_agg)
136
+ bluebook.aggregates.flat_map do |aggregate|
137
+ aggregate.entities.map do |entity|
138
+ Entry.new(term: entity.hecks_name, kind: "Entity", within: aggregate.hecks_name,
139
+ section: holder_agg[aggregate.hecks_name], definition: entity.description)
140
+ end
141
+ end
142
+ end
143
+
144
+ # A VALUE OBJECT CARRIES NO `description` — the language never gave
145
+ # it one (`Bluebook::ValueObject` declares `attributes`,
146
+ # `invariants`, `members`, `closed_set`, nothing else). Its
147
+ # definition is derived from its own shape instead: a closed set
148
+ # states its members, an open one its fields — the same
149
+ # distinction `DocsProjector#shape_of` draws.
150
+ #
151
+ # AGGREGATES ONLY, not `holders` — unlike commands and queries, an
152
+ # entity declares no value objects of its own (`Bluebook::Entity`
153
+ # deliberately does not answer `value_objects`; its argument types
154
+ # live on the aggregate above it, same as `DocsProjector#value_object_for`
155
+ # already has to account for).
156
+ def value_object_entries(bluebook, holder_agg)
157
+ bluebook.aggregates.flat_map do |aggregate|
158
+ aggregate.value_objects.map do |vo|
159
+ Entry.new(term: vo.hecks_name, kind: "Value Object", within: aggregate.hecks_name,
160
+ section: holder_agg[aggregate.hecks_name], definition: value_object_definition(vo))
161
+ end
162
+ end
163
+ end
164
+
165
+ # THE SHAPE, THEN THE RULES — a value object's own `invariant`
166
+ # bodies are real authored prose ("a currency is a three-letter
167
+ # code"), not derived the way the shape above is, and were
168
+ # sitting unread by this projector until now: `to_h`'s own
169
+ # `invariants` field already carries them, one `description` per
170
+ # `Bluebook::Invariant`.
171
+ def value_object_definition(value_object)
172
+ sentence = value_object_shape(value_object)
173
+ rules = value_object.invariants.map(&:description)
174
+ sentence += " Must satisfy: #{rules.join('; ')}." unless rules.empty?
175
+ sentence
176
+ end
177
+
178
+ # A CLOSED SET'S ROWS, not just their first field — the same
179
+ # correction `Projections::Vocabulary` already had to make
180
+ # (`StatementFrequency` names retention months and a paper fee
181
+ # alongside its cadence; flattening every field into one list
182
+ # produces well-formed nonsense, same as it did there).
183
+ def value_object_shape(value_object)
184
+ if value_object.closed_set?
185
+ if value_object.members.first && value_object.members.first.size > 1
186
+ rows = value_object.members.map { |row| "{ #{row.map { |f, v| "#{f}: #{v.inspect}" }.join(', ')} }" }
187
+ "One of: #{rows.join('; ')}."
188
+ else
189
+ "One of #{value_object.members.flat_map(&:values).uniq.map { |m| "`#{m}`" }.join(', ')}."
190
+ end
191
+ elsif value_object.attributes.empty?
192
+ "A marker with no fields of its own."
193
+ else
194
+ "{ #{value_object.attributes.map { |f| "#{f.name}: #{f.type}" }.join(', ')} }"
195
+ end
196
+ end
197
+
198
+ def command_entries(bluebook, holder_agg)
199
+ holders(bluebook).flat_map do |holder|
200
+ holder.commands.map do |command|
201
+ Entry.new(term: command.hecks_name, kind: "Command", within: holder.hecks_name,
202
+ section: holder_agg[holder.hecks_name], definition: command.goal)
203
+ end
204
+ end
205
+ end
206
+
207
+ def query_entries(bluebook, holder_agg)
208
+ holders(bluebook).flat_map do |holder|
209
+ holder.queries.map do |query|
210
+ Entry.new(term: query.hecks_name, kind: "Query", within: holder.hecks_name,
211
+ section: holder_agg[holder.hecks_name], definition: query.description)
212
+ end
213
+ end
214
+ end
215
+
216
+ # CROSS-AGGREGATE, ON PURPOSE — a read model joins heads from more
217
+ # than one aggregate (`ReadModel`'s own header: "an ask that
218
+ # gathers heads from more than one aggregate"), so it belongs to
219
+ # no single one and gets its own trailing section instead.
220
+ def read_model_entries(bluebook)
221
+ bluebook.read_models.map do |read_model|
222
+ Entry.new(term: read_model.name, kind: "Read Model", section: READ_MODELS_SECTION,
223
+ definition: read_model.description)
224
+ end
225
+ end
226
+
227
+ # AN EVENT IS NEVER ITS OWN DECLARATION — the language only ever
228
+ # spells one as a bare string in a command's `emits:`. So it earns
229
+ # a glossary entry by appearing there, and its "definition" is the
230
+ # one fact the graph actually holds about it: which verb(s) raise
231
+ # it, and — the other half nothing raises without a reader
232
+ # crossing over to check `bluebook.policies` by hand — which
233
+ # policy (if any) reacts to it. That is derived, not invented —
234
+ # nobody wrote a sentence this restates.
235
+ def event_entries(bluebook, event_agg)
236
+ by_event = Hash.new { |h, k| h[k] = [] }
237
+ holders(bluebook).each do |holder|
238
+ holder.commands.each do |command|
239
+ command.emits.each { |event| by_event[event] << command.hecks_name }
240
+ end
241
+ end
242
+ by_event.map do |event, commands|
243
+ sentence = "Raised by #{commands.uniq.map { |c| "`#{c}`" }.join(', ')}."
244
+ reactions = policies_on(bluebook, event)
245
+ sentence += " #{reactions.join(' ')}" unless reactions.empty?
246
+ Entry.new(term: event, kind: "Event", section: event_agg[event], definition: sentence)
247
+ end
248
+ end
249
+
250
+ # A POLICY NAMES ITS EVENT QUALIFIED (`"Account.AccountFrozen"`) —
251
+ # `bare` strips the aggregate a policy's own `on_event`/
252
+ # `trigger_command` always carries but an event's own glossary
253
+ # term (keyed off `emits:`, which is never qualified) never does.
254
+ def bare(qualified) = qualified.to_s.split(".").last
255
+
256
+ def policies_on(bluebook, event)
257
+ bluebook.policies.select { |policy| bare(policy.on_event) == event }.map do |policy|
258
+ domain = " in #{policy.target_domain}" if policy.target_domain
259
+ "Triggers policy `#{policy.name}` → `#{policy.trigger_command}`#{domain}."
260
+ end
261
+ end
262
+
263
+ # A ROLE IS THE SAME SHAPE OF FACT AS AN EVENT — free text on a
264
+ # command's `role:`, never declared on its own — so it gets the
265
+ # same treatment: who it is, told by what it does. CROSS-CUTTING
266
+ # BY NATURE (`System`/`Customer` issue commands across half the
267
+ # aggregates in this corpus), so — like a Read Model — it belongs
268
+ # to no single aggregate.
269
+ def role_entries(bluebook)
270
+ by_role = Hash.new { |h, k| h[k] = [] }
271
+ holders(bluebook).each do |holder|
272
+ holder.commands.select(&:role).each { |command| by_role[command.role] << command.hecks_name }
273
+ end
274
+ by_role.map do |role, commands|
275
+ Entry.new(term: role, kind: "Role", section: ROLES_SECTION,
276
+ definition: "Issues #{commands.uniq.map { |c| "`#{c}`" }.join(', ')}.")
277
+ end
278
+ end
279
+
280
+ # A LIFECYCLE HAS NO NAME OF ITS OWN — it is a body nested inside
281
+ # the aggregate or entity it governs (`lifecycle :status do ...
282
+ # end`), so it earns a glossary entry under ITS HOLDER's term,
283
+ # the same way `Command`/`Query` do — a reader who looks up
284
+ # `Account` and a reader who looks up what states an Account can
285
+ # hold are asking two different questions of the same name.
286
+ def lifecycle_entries(bluebook, holder_agg)
287
+ holders(bluebook).select(&:lifecycle).map do |holder|
288
+ lifecycle = holder.lifecycle
289
+ states = ([lifecycle.default] + lifecycle.transitions.map { |_name, transition| transition.target }).uniq
290
+ Entry.new(term: holder.hecks_name, kind: "Lifecycle", section: holder_agg[holder.hecks_name],
291
+ definition: "Starts at `#{lifecycle.default}`. States: #{states.map { |s| "`#{s}`" }.join(', ')}.")
292
+ end
293
+ end
294
+
295
+ # A SAGA'S OWN HANDLERS/DISPATCHES ARE NAMELESS TOO — the same
296
+ # shape as a Lifecycle's transitions, one level up: a sequence of
297
+ # states no individual step is worth its own glossary row for.
298
+ # `states`, from the builder's own precomputed leg order
299
+ # (`Behaviour::ProcessManager#saga`, the same field
300
+ # `DocsProjector#closing` already reads), says what a Lifecycle
301
+ # entry says for an aggregate — the shape of the whole journey,
302
+ # not just where it starts and ends. HOMED ON ITS STARTING EVENT —
303
+ # a saga usually ends somewhere else entirely (that is most of
304
+ # what makes it a saga), so "where it begins" is the only single
305
+ # aggregate honestly its own.
306
+ def saga_entries(bluebook, event_agg)
307
+ bluebook.process_managers.map do |saga|
308
+ shape = saga.to_h
309
+ sentence = "Starts on `#{shape[:starts_on]}`, ends on `#{shape[:ends_on]}`."
310
+ states = Array(shape[:states])
311
+ sentence += " States: #{states.map { |s| "`#{s}`" }.join(' → ')}." unless states.empty?
312
+ Entry.new(term: shape[:name], kind: "Saga", section: event_agg[bare(shape[:starts_on])],
313
+ definition: sentence)
314
+ end
315
+ end
316
+
317
+ # A POLICY IS A NAMED CONSTRUCT TOO (`policy "ReviewOnFreeze" do
318
+ # ... end`), the same as a Saga — so it earns its own glossary
319
+ # entry rather than surfacing only as a parenthetical on the
320
+ # event that triggers it. HOMED ON THE EVENT IT REACTS TO, not
321
+ # what it dispatches — `on_event` is always local to this
322
+ # bluebook, `trigger_command` sometimes is not (a cross-domain
323
+ # policy's own `across`). `for_each`, when declared, fans the
324
+ # dispatch out over more than one record; stated here because a
325
+ # reader who only sees "dispatches X" would otherwise assume one.
326
+ def policy_entries(bluebook, event_agg)
327
+ bluebook.policies.map do |policy|
328
+ sentence = "On `#{bare(policy.on_event)}`, dispatches `#{policy.trigger_command}`"
329
+ sentence += " in #{policy.target_domain}" if policy.target_domain
330
+ sentence += ", once per `#{policy.for_each}` row" if policy.for_each
331
+ Entry.new(term: policy.name, kind: "Policy", section: event_agg[bare(policy.on_event)],
332
+ definition: "#{sentence}.")
333
+ end
334
+ end
335
+
336
+ # ── rendering ─────────────────────────────────────────────────────
337
+
338
+ def render(bluebook)
339
+ all = entries(bluebook)
340
+ grouped = all.reject { |entry| entry.kind == "Domain" }.group_by { |entry| entry.section || "Cross-Domain Reactions" }
341
+
342
+ out = [chapter_header(bluebook)]
343
+ bluebook.aggregates.map(&:hecks_name).sort.each { |name| out << section(name, grouped[name]) if grouped[name] }
344
+ [ROLES_SECTION, READ_MODELS_SECTION, "Cross-Domain Reactions"].each do |name|
345
+ out << section(name, grouped[name]) if grouped[name]
346
+ end
347
+
348
+ "#{out.compact.join("\n\n")}\n"
349
+ end
350
+
351
+ def chapter_header(bluebook)
352
+ out = ["# #{bluebook.name} — Glossary", ""]
353
+ out += ["> #{bluebook.vision}", ""] if bluebook.vision
354
+ out << "The ubiquitous language: every term #{bluebook.name} declares, grouped under the aggregate " \
355
+ "it belongs to, in the domain's own words. Generated from `#{bluebook.name}`'s bluebook — a " \
356
+ "term missing here is a term the bluebook does not yet declare, and a definition missing here " \
357
+ "is a sentence nobody has written yet."
358
+ out.join("\n")
359
+ end
360
+
361
+ def section(title, rows)
362
+ return nil unless rows
363
+
364
+ sorted = rows.sort_by { |entry| [KIND_RANK[entry.kind] || 99, entry.term.downcase] }
365
+ "## #{title}\n\n| Term | Kind | Definition |\n|---|---|---|\n#{sorted.map { |entry| entry_row(entry) }.join("\n")}"
366
+ end
367
+
368
+ def entry_row(entry)
369
+ term = entry.within ? "**#{entry.term}** *(#{entry.within})*" : "**#{entry.term}**"
370
+ "| #{term} | #{entry.kind} | #{entry.definition || '—'} |"
371
+ end
372
+ end
373
+ end
374
+ end
@@ -1,6 +1,8 @@
1
1
  require_relative "../projector"
2
2
 
3
3
  module Hecks
4
+ # Reopened from projections.rb (see there for the namespace's full
5
+ # rationale) to define and register the :ir target below.
4
6
  module Projections
5
7
  # The canonical IR, as a constant. The implementation already existed
6
8
  # and is already golden-tested (`Projector::IRProjector`, registered
@@ -60,7 +60,8 @@ module Hecks
60
60
  "Bluebook" => { ir_version: "the EMISSION's own version, not the domain's",
61
61
  canonical_form: "the normalisation table every reader needs beside the IR" },
62
62
  "ValueObject" => { closed_set: "an empty one_of and no one_of are otherwise indistinguishable" },
63
- "Aggregate" => { ports: "declared in the hecksagon, attached after the aggregate exists" }
63
+ "Aggregate" => { ports: "declared in the hecksagon, attached after the aggregate exists" },
64
+ "Policy" => { where_ast: "the structured form of `where`, derived from the same text at emission" }
64
65
  }.freeze
65
66
 
66
67
  # DECLARED, AND DELIBERATELY NOT EMITTED. The category that had no
@@ -50,7 +50,11 @@ module Hecks
50
50
  # a binding that reads an event field and one that supplies a
51
51
  # literal string are otherwise indistinguishable once written
52
52
  # down (see `MetaValidator::Readings`' own note on exactly that).
53
- renders: { with_spec: "-> { with_spec.map { |key, value| [key.to_s, Bluebook.render_value(value)] } }" },
53
+ renders: { with_spec: "-> { with_spec.map { |key, value| [key.to_s, Bluebook.render_value(value)] } }",
54
+ # COMPUTED (Deviations::COMPUTED["Policy"]): the structured
55
+ # form of `where`, a pure function of that text, the same
56
+ # `ast` every rule row carries beside its `canonical`.
57
+ where_ast: "-> { where_ast }" },
54
58
  settles: false
55
59
  }
56
60
  }.freeze
@@ -66,7 +70,7 @@ module Hecks
66
70
  # GENERATED — projected from the language's own #{name} aggregate.
67
71
  # DO NOT EDIT: the holding half is rendered, and #{host.fetch(:behaviour)}
68
72
  # is where anything hand-written belongs.
69
- require_relative "behaviour/#{File.basename(host.fetch(:file), ".rb")}"
73
+ require_relative "behaviour/#{File.basename(host.fetch(:file), '.rb')}"
70
74
 
71
75
  module Hecks
72
76
  module Bluebook
@@ -87,14 +91,15 @@ module Hecks
87
91
 
88
92
  # The emission, keyed as the model spells it and sourced as the
89
93
  # language declares it.
94
+ # A COMPUTED field (Deviations::COMPUTED) is emitted too — it is
95
+ # model-only by definition, so it rides after the declared fields
96
+ # and MUST have a `renders` entry, there being nothing to `send`.
90
97
  def emits(bluebook, name)
91
- fields = emitted_fields(bluebook, name)
98
+ fields = emitted_fields(bluebook, name) + Deviations.computed(name)
92
99
  renders = HOST.fetch(name).fetch(:renders, {})
93
100
  width = fields.map { |f| f.to_s.length }.max.to_i
94
101
 
95
- "emits_ir(\n" +
96
- fields.map { |f| " #{"#{f}:".ljust(width + 1)} #{renders.fetch(f, ":#{f}")}" }.join(",\n") +
97
- "\n)"
102
+ "emits_ir(\n#{fields.map { |f| " #{"#{f}:".ljust(width + 1)} #{renders.fetch(f, ":#{f}")}" }.join(",\n")}\n)"
98
103
  end
99
104
 
100
105
  # WHAT THE CONSTRUCT EMITS: what the language declares, less every
@@ -121,18 +126,18 @@ module Hecks
121
126
  # here rather than typed in — a comment that survives
122
127
  # regeneration is one the generator writes.
123
128
  reasons = Deviations::OFF_THE_WIRE.fetch(host.fetch(:construct, ""), {})
124
- (lines + accessors.map { |a|
129
+ (lines + accessors.map do |a|
125
130
  why = reasons[a]
126
131
  (why ? "\n# #{a.upcase}, DECLARED AND DELIBERATELY OFF THE WIRE\n# #{wrap(why)}\n" : "") +
127
132
  "attr_accessor :#{a}"
128
- }).join("\n")
133
+ end).join("\n")
129
134
  end
130
135
 
131
136
  def constructor(host)
132
137
  args = host.fetch(:defaults).map { |f, d| d ? "#{f}: #{d}" : "#{f}:" }.join(", ")
133
- body = host.fetch(:defaults).keys.map { |f|
138
+ body = host.fetch(:defaults).keys.map do |f|
134
139
  " @#{f} = #{f}#{host.fetch(:coerce, {})[f]}"
135
- }
140
+ end
136
141
  body << "\n settle" if host.fetch(:settles, true)
137
142
 
138
143
  "def initialize(#{args})\n#{body.join("\n")}\nend"
@@ -58,9 +58,7 @@ module Hecks
58
58
 
59
59
  def render(bluebook)
60
60
  rows = tables(bluebook).sort_by(&:first).map do |name, members|
61
- " #{name.inspect} => [\n" +
62
- members.map { |row| " #{row.inspect}.freeze" }.join(",\n") +
63
- "\n ].freeze"
61
+ " #{name.inspect} => [\n#{members.map { |row| " #{row.inspect}.freeze" }.join(",\n")}\n ].freeze"
64
62
  end
65
63
 
66
64
  <<~RUBY
@@ -30,3 +30,4 @@ require_relative "projections/reference"
30
30
  require_relative "projections/model"
31
31
  require_relative "projections/diagrams"
32
32
  require_relative "projections/statements"
33
+ require_relative "projections/glossary"
@@ -54,12 +54,12 @@ module Hecks
54
54
  aggregate.queries.each { |q| claim(questions, name_for(aggregate, q), query_spec(bluebook, aggregate, nil, q)) }
55
55
 
56
56
  aggregate.entities.each do |entity|
57
- entity.commands.each { |c|
57
+ entity.commands.each do |c|
58
58
  claim(verbs, name_for(aggregate, c, entity), command_spec(bluebook, aggregate, entity, c))
59
- }
60
- entity.queries.each { |q|
59
+ end
60
+ entity.queries.each do |q|
61
61
  claim(questions, name_for(aggregate, q, entity), query_spec(bluebook, aggregate, entity, q))
62
- }
62
+ end
63
63
  end
64
64
 
65
65
  # A PORT IS A VERB TOO, and leaving it off the map was a real gap
@@ -156,6 +156,33 @@ module Hecks
156
156
  entity ? "#{entity.hecks_name}." : "", verb.hecks_name].join
157
157
  end
158
158
 
159
+ # THE ARGUMENTS A RECEIVER ADDS, BEFORE ANY VERB-SPECIFIC ONE. Shared by
160
+ # `command_spec` and `port_spec` — a port operation always addresses an
161
+ # aggregate record (`port_spec` passes `receiver: :aggregate`, never
162
+ # `:entity` or `nil`, because a port is declared on an aggregate, never
163
+ # an entity), and this is exactly the same "how do I name the record"
164
+ # question a command with a receiver already answers, so it is answered
165
+ # once, here, rather than duplicated.
166
+ #
167
+ # `nil` — a creating command — takes none: there is no existing record
168
+ # yet for `to=` to name.
169
+ def receiver_options(receiver, aggregate, entity)
170
+ case receiver
171
+ when :entity
172
+ [
173
+ { path: "to.aggregate", type: "String", required: true,
174
+ note: "id of the #{aggregate.hecks_name} holding the #{entity.hecks_name}" },
175
+ { path: "to.entity", type: "String", required: true,
176
+ note: "id of the #{entity.hecks_name} to act on" }
177
+ ]
178
+ when :aggregate
179
+ [{ path: "to", type: "String", required: true,
180
+ note: "id of the #{aggregate.hecks_name} to act on" }]
181
+ else
182
+ []
183
+ end
184
+ end
185
+
159
186
  # ── one verb ──────────────────────────────────────────────────────
160
187
 
161
188
  def command_spec(bluebook, aggregate, entity, command)
@@ -166,7 +193,6 @@ module Hecks
166
193
  else
167
194
  (command.creates? ? nil : :aggregate)
168
195
  end
169
- legacy_arguments = []
170
196
 
171
197
  # THE RECEIVER IS NOT A COMMAND ARGUMENT. An aggregate command names
172
198
  # its record through to; an entity command needs both the aggregate
@@ -178,18 +204,8 @@ module Hecks
178
204
  # Existing aggregate scripts may still spell the receiver id=... .
179
205
  # That alias is deliberately hidden from help and recorded separately
180
206
  # as legacy_arguments; new help and examples teach only to=... .
181
- if entity
182
- arguments = [
183
- { path: "to.aggregate", type: "String", required: true,
184
- note: "id of the #{aggregate.hecks_name} holding the #{entity.hecks_name}" },
185
- { path: "to.entity", type: "String", required: true,
186
- note: "id of the #{entity.hecks_name} to act on" }
187
- ] + arguments
188
- elsif receiver == :aggregate
189
- arguments = [{ path: "to", type: "String", required: true,
190
- note: "id of the #{aggregate.hecks_name} to act on" }] + arguments
191
- legacy_arguments = [{ path: "id", type: "String", required: true }]
192
- end
207
+ arguments = receiver_options(receiver, aggregate, entity) + arguments
208
+ legacy_arguments = receiver == :aggregate ? [{ path: "id", type: "String", required: true }] : []
193
209
 
194
210
  { verb: fqn(bluebook, aggregate, command, entity), kind: :command,
195
211
  summary: command.goal, role: command.role, role_gated: !command.role.to_s.empty?,
@@ -235,7 +251,11 @@ module Hecks
235
251
  # `CommandInterpreter`/`EntityInterpreter` call it, never the port
236
252
  # dispatch path), so `role_gated: false` always, unlike
237
253
  # `command_spec` where the same key name means a real one.
238
- role: operation.outbound? ? "#{aggregate.hecks_name} asking #{port.name}" : "#{port.name} telling #{aggregate.hecks_name}",
254
+ role: if operation.outbound?
255
+ "#{aggregate.hecks_name} asking #{port.name}"
256
+ else
257
+ "#{port.name} telling #{aggregate.hecks_name}"
258
+ end,
239
259
  role_gated: false,
240
260
  summary: port_summary(port, operation), arguments: arguments }
241
261
  end
@@ -289,8 +309,8 @@ module Hecks
289
309
  # malformed records into a real store, which is what it did on its first
290
310
  # run against the pizzas database.
291
311
  def options_for(attribute, holder, aggregate, prefix = nil, optional = nil)
292
- path = [prefix, attribute.name].compact.join(".")
293
- optional = optional || attribute.optional?
312
+ path = [prefix, attribute.name].compact.join(".")
313
+ optional ||= attribute.optional?
294
314
  return [reference_option(attribute)] if attribute.reference?
295
315
 
296
316
  value_object = value_object_for(attribute, holder, aggregate)
@@ -408,38 +428,54 @@ module Hecks
408
428
  text.to_s.split(/(?<=\.)\s/).first.to_s
409
429
  end
410
430
 
431
+ # FOUR TEXT BLOCKS, IN FIXED DISPLAY ORDER — meta (name/kind/role),
432
+ # invocation, arguments, refusals. Each block is independent of the
433
+ # others' content (only the OUTPUT ORDER is fixed, and stays fixed
434
+ # below), so each is its own method returning the lines it
435
+ # contributes — `[]` when it contributes none — concatenated in the
436
+ # same order the original inline version built them in.
411
437
  def verb_help(program, name, spec, ask: false)
438
+ out = verb_help_meta_lines(name, spec)
439
+ out.concat(verb_help_invocation_lines(program, name, spec, ask))
440
+ out.concat(verb_help_argument_lines(spec))
441
+ out.concat(verb_help_refusal_lines(spec))
442
+ out.join("\n")
443
+ end
444
+
445
+ def verb_help_meta_lines(name, spec)
412
446
  out = ["#{name} — #{spec[:summary]}", ""]
413
447
  out << "dispatches #{spec[:verb]}" if spec[:kind] == :command
414
448
  out << "reads #{spec[:verb]}" if spec[:kind] == :query
415
449
  out << "issued by #{spec[:role]}" if spec[:role]
416
- out << ""
450
+ out
451
+ end
452
+
453
+ def verb_help_invocation_lines(program, name, spec, ask)
417
454
  invocation = ask ? "#{program} ask #{name}" : "#{program} #{name}"
418
- out << " #{invocation}#{spec[:arguments].map { |a| " #{a[:path]}=…" }.join}"
419
- out << ""
455
+ ["", " #{invocation}#{spec[:arguments].map { |a| " #{a[:path]}=…" }.join}", ""]
456
+ end
420
457
 
421
- unless spec[:arguments].empty?
422
- width = spec[:arguments].map { |a| a[:path].length }.max
423
- spec[:arguments].each do |argument|
424
- notes = []
425
- notes << argument[:type]
426
- notes << "one of #{argument[:enum].join(', ')}" if argument[:enum]
427
- notes << "matches #{argument[:pattern]}" if argument[:pattern]
428
- notes << "defaults to #{argument[:default].inspect}" unless argument[:default].nil?
429
- notes << argument[:note] if argument[:note]
430
- notes << "optional" unless argument[:required]
431
- out << " #{argument[:path].ljust(width)} #{notes.join('; ')}"
432
- end
433
- out << ""
458
+ def verb_help_argument_lines(spec)
459
+ return [] if spec[:arguments].empty?
460
+
461
+ width = spec[:arguments].map { |a| a[:path].length }.max
462
+ lines = spec[:arguments].map do |argument|
463
+ notes = []
464
+ notes << argument[:type]
465
+ notes << "one of #{argument[:enum].join(', ')}" if argument[:enum]
466
+ notes << "matches #{argument[:pattern]}" if argument[:pattern]
467
+ notes << "defaults to #{argument[:default].inspect}" unless argument[:default].nil?
468
+ notes << argument[:note] if argument[:note]
469
+ notes << "optional" unless argument[:required]
470
+ " #{argument[:path].ljust(width)} #{notes.join('; ')}"
434
471
  end
472
+ lines << ""
473
+ end
435
474
 
436
- unless Array(spec[:refusals]).empty?
437
- out << "refused when:"
438
- spec[:refusals].each { |refusal| out << " #{refusal}" }
439
- out << ""
440
- end
475
+ def verb_help_refusal_lines(spec)
476
+ return [] if Array(spec[:refusals]).empty?
441
477
 
442
- out.join("\n")
478
+ ["refused when:", *spec[:refusals].map { |refusal| " #{refusal}" }, ""]
443
479
  end
444
480
  end
445
481
  end