hecks 1.2.0 → 1.3.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
  3. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  4. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  5. data/lib/hecks/behaviors/expectations.rb +32 -4
  6. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  7. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  8. data/lib/hecks/bluebook/model_check.rb +148 -17
  9. data/lib/hecks/forms/field_shape.rb +5 -3
  10. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  11. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  12. data/lib/hecks/fuzzing/form_census.rb +184 -0
  13. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  14. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  15. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  16. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  17. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  18. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  19. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  20. data/lib/hecks/fuzzing/properties.rb +23 -2
  21. data/lib/hecks/fuzzing/replay.rb +130 -20
  22. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  23. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  24. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  25. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  26. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  27. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  28. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  29. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  30. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  31. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  32. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  33. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  34. data/lib/hecks/fuzzing.rb +6 -0
  35. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  36. data/lib/hecks/naming.rb +70 -2
  37. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  38. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  39. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
  40. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  41. data/lib/hecks/projections/glossary/html.rb +250 -0
  42. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  43. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  44. data/lib/hecks/projections/glossary/page.css +271 -0
  45. data/lib/hecks/projections/glossary/page.js +72 -0
  46. data/lib/hecks/projections/glossary/sections.rb +17 -0
  47. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  48. data/lib/hecks/projections/glossary.rb +214 -286
  49. data/lib/hecks/projector/narrate_projector.rb +4 -11
  50. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  51. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  52. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  53. data/lib/hecks/runtime/entity_element.rb +168 -16
  54. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  55. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  56. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  57. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  58. data/lib/hecks/runtime/registry.rb +12 -0
  59. data/lib/hecks/runtime/routing.rb +67 -2
  60. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  61. data/lib/hecks/runtime/value/coercion.rb +77 -115
  62. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  63. data/lib/hecks/runtime/value.rb +7 -2
  64. data/lib/hecks/version.rb +1 -1
  65. data/lib/hecks/vocabulary.rb +2 -1
  66. metadata +23 -2
@@ -1,78 +1,126 @@
1
1
  require_relative "../projector"
2
+ require_relative "../naming"
3
+ require_relative "statements"
4
+ require_relative "glossary/sections"
5
+ require_relative "glossary/sentences"
6
+ require_relative "glossary/mermaid"
7
+ require_relative "glossary/markdown"
8
+ require_relative "glossary/html"
2
9
 
3
10
  module Hecks
4
11
  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.
12
+ # A CHAPTER, PROJECTED AS ITS UBIQUITOUS LANGUAGE — a glossary for the
13
+ # whole team, in the sense Evans meant (DDD ch. 2): ONE language,
14
+ # shared by domain experts and developers, written the same way in
15
+ # conversation, diagrams, documents and code, so that a subject-matter
16
+ # expert can read the model and say "yes, that's how it works" or
17
+ # "no, that's wrong". A banker, a support rep, the CEO and an engineer
18
+ # read the same page.
14
19
  #
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.
20
+ # THREE THINGS FOLLOW FROM THAT AUDIENCE. First, no type jargon: a
21
+ # term is a term, not "an Aggregate" or "a Value Object", and every
22
+ # identifier is spelled as a person says it (`Naming.words`: `ATMCard`
23
+ # is "ATM card"). Second, nothing invented: every sentence is either
24
+ # the domain author's own words (a `description`, a `goal`, an
25
+ # invariant) or built mechanically from a declared fact ("Recorded
26
+ # after Freeze account") the same discipline `DocsProjector` holds
27
+ # to; a wrong sentence is worse than a missing one. Third, grouped
28
+ # under the aggregate each term belongs to, A to Z within it — a
29
+ # reader thinks "what does Account mean" before "what starts with A",
30
+ # and once there reads the way a dictionary reads.
28
31
  #
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.
32
+ # TWO FILES FROM ONE SOURCE. `glossary.md` is the document — every
33
+ # construct in it renders on GitHub as-is (headings, blockquotes,
34
+ # ```mermaid fences, lists, in-page links). `html/index.html` is that
35
+ # exact Markdown string rendered into a page with a navigation rail;
36
+ # it is built FROM the Markdown, not beside it, so the two cannot
37
+ # drift. `bin/project_glossary` writes both to `<domain>/glossary/` beside
38
+ # the bluebook (examples/banking, examples/pizzas, and the QA ledger in qa/).
36
39
  #
37
40
  # Projector.call(:glossary, bluebook: <the Bluebook chapter>)
41
+ # # => { "glossary.md" => "...", "html/index.html" => "..." }
38
42
  module Glossary
39
43
  extend Projector::Target
40
44
 
41
- projects_as :glossary
45
+ projects_as :glossary, emits: :files
42
46
 
43
- Entry = Struct.new(:term, :kind, :definition, :within, :section, keyword_init: true)
47
+ # One term. `kind` is a Symbol the renderers never print; `facts`
48
+ # carries the declaration objects the sentence is built from;
49
+ # `headword` and `slug` are assigned once the document's order is
50
+ # known (a headword may need qualifying, a slug depends on what
51
+ # came before it).
52
+ Entry = Struct.new(:name, :kind, :within, :section, :facts, :headword, :slug, keyword_init: true)
44
53
 
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
54
+ # One `##` of the document: an aggregate (with its own object, for
55
+ # the lede and diagrams) or one of the three trailing groups.
56
+ Section = Struct.new(:name, :title, :aggregate, :terms, :slug, keyword_init: true)
52
57
 
53
- ROLES_SECTION = "Roles".freeze
54
- READ_MODELS_SECTION = "Read Models".freeze
58
+ Document = Struct.new(:bluebook, :sections, :index, keyword_init: true)
55
59
 
56
60
  module_function
57
61
 
58
- def call(bluebook:, options: {}) = render(bluebook)
62
+ def call(bluebook:, options: {})
63
+ markdown = Markdown.render(document(bluebook))
64
+ { "glossary.md" => markdown, "html/index.html" => Html.render(markdown) }
65
+ end
66
+
67
+ # ── the document ─────────────────────────────────────────────────
59
68
 
60
- # ── gathering ─────────────────────────────────────────────────────
69
+ def document(bluebook)
70
+ sections = sections(bluebook, entries(bluebook))
71
+ Slugs.assign!(bluebook, sections)
72
+ Document.new(bluebook: bluebook, sections: sections, index: Index.new(sections))
73
+ end
61
74
 
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.
75
+ # AGGREGATES FIRST, A TO Z BY THEIR SPOKEN NAME ("Account" before
76
+ # "ATM card"), then the three groups nothing homes to one aggregate.
77
+ def sections(bluebook, entries)
78
+ grouped = entries.group_by(&:section)
79
+ list = bluebook.aggregates.sort_by { |aggregate| Naming.words(aggregate.hecks_name).downcase }.map do |aggregate|
80
+ Section.new(name: aggregate.hecks_name, title: Naming.words(aggregate.hecks_name), aggregate: aggregate,
81
+ terms: with_headwords(grouped.fetch(aggregate.hecks_name, []), aggregate.hecks_name))
82
+ end
83
+ [ROLES, READ_MODELS, REACTIONS].each do |name|
84
+ held = grouped[name == REACTIONS ? nil : name]
85
+ list << Section.new(name: name, title: name, terms: with_headwords(held, name)) if held
86
+ end
87
+ list
88
+ end
89
+
90
+ # A HEADWORD IS QUALIFIED ONLY WHEN IT WOULD REPEAT within its own
91
+ # section — "Open" the action and "Open (the list)" the question;
92
+ # "Return (key issuance)" beside another Return — never numbered
93
+ # (Chicago 18.9, MDN's disambiguation pages: qualify the headword).
94
+ def with_headwords(entries, section_name)
95
+ entries.each { |entry| entry.headword = Naming.words(entry.name) }
96
+ entries.group_by(&:headword).each_value do |group|
97
+ next if group.size == 1
98
+
99
+ group.each { |entry| entry.headword = qualified(entry, group, section_name) }
100
+ end
101
+ entries.sort_by { |entry| [entry.headword.downcase, entry.kind.to_s] }
102
+ end
103
+
104
+ def qualified(entry, group, section_name)
105
+ base = Naming.words(entry.name)
106
+ return "#{base} (the list)" if entry.kind == :query && group.any? { |other| other.kind == :command }
107
+ return "#{base} (#{Naming.words(entry.within).downcase})" if entry.within && entry.within != section_name
108
+
109
+ base
110
+ end
111
+
112
+ # ── gathering ────────────────────────────────────────────────────
113
+
114
+ # AN ENTITY CAN CARRY ITS OWN COMMANDS AND QUERIES TOO — walked the
115
+ # same one level down `DocsProjector` and `Projections::Diagrams`
116
+ # already walk it.
66
117
  def holders(bluebook)
67
118
  bluebook.aggregates.flat_map { |aggregate| [aggregate, *aggregate.entities] }
68
119
  end
69
120
 
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.
121
+ # EVERY HOLDER'S OWN AGGREGATE, ONE HOP OR ZERO — an aggregate maps
122
+ # to itself, an entity to whichever aggregate declared it. The one
123
+ # fact the grouping is built on.
76
124
  def holder_aggregate(bluebook)
77
125
  bluebook.aggregates.each_with_object({}) do |aggregate, map|
78
126
  map[aggregate.hecks_name] = aggregate.hecks_name
@@ -80,294 +128,174 @@ module Hecks
80
128
  end
81
129
  end
82
130
 
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 } }
131
+ # EVERY EVENT'S RAISERS — an event is never declared, only emitted,
132
+ # so its home is whichever aggregate the FIRST command that raises
133
+ # it belongs to; a policy or saga reacting to it inherits that home.
134
+ def event_raisers(bluebook)
135
+ holders(bluebook).each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |holder, map|
136
+ holder.commands.each { |command| command.emits.each { |event| map[event] << [holder, command] } }
93
137
  end
94
- map
95
138
  end
96
139
 
140
+ def bare(qualified) = qualified.to_s.split(".").last
141
+
97
142
  def entries(bluebook)
98
- holder_agg = holder_aggregate(bluebook)
99
- event_agg = event_aggregate(bluebook, holder_agg)
143
+ homes = holder_aggregate(bluebook)
144
+ raisers = event_raisers(bluebook)
145
+ home_of = ->(event) { raisers.key?(event) ? homes[raisers[event].first.first.hecks_name] : nil }
100
146
 
101
147
  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)
148
+ entries += entity_entries(bluebook)
149
+ entries += value_object_entries(bluebook)
150
+ entries += verb_entries(bluebook, homes)
151
+ entries += event_entries(bluebook, raisers, home_of)
152
+ entries += policy_entries(bluebook, home_of)
153
+ entries += saga_entries(bluebook, home_of)
110
154
  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)
155
+ entries += read_model_entries(bluebook)
114
156
  entries
115
157
  end
116
158
 
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)
159
+ def entity_entries(bluebook)
136
160
  bluebook.aggregates.flat_map do |aggregate|
137
161
  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)
162
+ Entry.new(name: entity.hecks_name, kind: :entity, within: aggregate.hecks_name,
163
+ section: aggregate.hecks_name, facts: { entity: entity })
140
164
  end
141
165
  end
142
166
  end
143
167
 
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)
168
+ # AGGREGATES ONLY an entity declares no value objects of its own
169
+ # (`Bluebook::Entity` deliberately does not answer `value_objects`).
170
+ def value_object_entries(bluebook)
157
171
  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))
172
+ aggregate.value_objects.map do |value_object|
173
+ Entry.new(name: value_object.hecks_name, kind: :value_object, within: aggregate.hecks_name,
174
+ section: aggregate.hecks_name, facts: { value_object: value_object })
161
175
  end
162
176
  end
163
177
  end
164
178
 
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)
179
+ def verb_entries(bluebook, homes)
199
180
  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)
181
+ commands = holder.commands.map do |command|
182
+ Entry.new(name: command.hecks_name, kind: :command, within: holder.hecks_name,
183
+ section: homes[holder.hecks_name], facts: { command: command, holder: holder })
203
184
  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)
185
+ queries = holder.queries.map do |query|
186
+ Entry.new(name: query.hecks_name, kind: :query, within: holder.hecks_name,
187
+ section: homes[holder.hecks_name], facts: { query: query })
212
188
  end
189
+ commands + queries
213
190
  end
214
191
  end
215
192
 
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)
193
+ def event_entries(bluebook, raisers, home_of)
194
+ raisers.map do |event, raised_by|
195
+ reactions = bluebook.policies.select { |policy| bare(policy.on_event) == event }
196
+ Entry.new(name: event, kind: :event, section: home_of.call(event),
197
+ facts: { event: event, raised_by: raised_by, policies: reactions })
224
198
  end
225
199
  end
226
200
 
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)
201
+ def policy_entries(bluebook, home_of)
202
+ bluebook.policies.map do |policy|
203
+ Entry.new(name: policy.name, kind: :policy, section: home_of.call(bare(policy.on_event)),
204
+ facts: { policy: policy })
247
205
  end
248
206
  end
249
207
 
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}."
208
+ def saga_entries(bluebook, home_of)
209
+ bluebook.process_managers.map do |saga|
210
+ shape = saga.to_h
211
+ Entry.new(name: shape[:name], kind: :saga, section: home_of.call(bare(shape[:starts_on])),
212
+ facts: { saga: shape })
260
213
  end
261
214
  end
262
215
 
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.
216
+ # CROSS-CUTTING BY NATURE `System` and `Customer` issue commands
217
+ # across half the aggregates here — so a role belongs to no single
218
+ # one and gets its own section.
269
219
  def role_entries(bluebook)
270
- by_role = Hash.new { |h, k| h[k] = [] }
220
+ by_role = Hash.new { |hash, key| hash[key] = [] }
271
221
  holders(bluebook).each do |holder|
272
- holder.commands.select(&:role).each { |command| by_role[command.role] << command.hecks_name }
222
+ holder.commands.select(&:role).each { |command| by_role[command.role] << [holder, command] }
273
223
  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(', ')}.")
224
+ by_role.map do |role, issues|
225
+ Entry.new(name: role, kind: :role, section: ROLES, facts: { role: role, commands: issues })
292
226
  end
293
227
  end
294
228
 
295
- # A SAGA'S OWN HANDLERS/DISPATCHES ARE NAMELESS TOOthe 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)
229
+ # A read model joins heads from more than one aggregate its own
230
+ # header says so so it belongs to none of them.
231
+ def read_model_entries(bluebook)
232
+ bluebook.read_models.map do |read_model|
233
+ Entry.new(name: read_model.name, kind: :read_model, section: READ_MODELS, facts: { read_model: read_model })
314
234
  end
315
235
  end
316
236
 
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
237
+ # ── anchors ──────────────────────────────────────────────────────
335
238
 
336
- # ── rendering ─────────────────────────────────────────────────────
239
+ # GITHUB'S OWN HEADING SLUGS, REPRODUCED — lowercase, punctuation
240
+ # dropped, spaces to hyphens, a repeat gets "-1", "-2" in document
241
+ # order. Computed here, once, in the order the headings will appear,
242
+ # so a link written into the Markdown lands on the same heading
243
+ # whether GitHub renders the `.md` or `Html` renders the page.
244
+ module Slugs
245
+ module_function
337
246
 
338
- def render(bluebook)
339
- all = entries(bluebook)
340
- grouped = all.reject { |entry| entry.kind == "Domain" }.group_by { |entry| entry.section || "Cross-Domain Reactions" }
247
+ def github(text) = text.to_s.downcase.gsub(/[^\p{Word}\- ]/, "").tr(" ", "-")
341
248
 
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]
249
+ def assign!(bluebook, sections)
250
+ seen = Hash.new(0)
251
+ take = lambda do |text|
252
+ base = github(text)
253
+ seen[base] += 1
254
+ seen[base] == 1 ? base : "#{base}-#{seen[base] - 1}"
255
+ end
256
+ take.call(Markdown.title(bluebook))
257
+ sections.each do |section|
258
+ section.slug = take.call(section.title)
259
+ section.terms.each { |entry| entry.slug = take.call(entry.headword) }
260
+ end
346
261
  end
347
-
348
- "#{out.compact.join("\n\n")}\n"
349
262
  end
350
263
 
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
264
+ # WHERE A DECLARED REFERENCE POINTS — keyed structurally (a command
265
+ # by its holder AND name, an event by its bare name), never by
266
+ # searching prose for a matching word. Anything not declared here
267
+ # (a cross-domain command like `Notifications.Send`) answers nil,
268
+ # and the sentence says it in words with no link.
269
+ class Index
270
+ def initialize(sections)
271
+ @by_key = {}
272
+ sections.each do |section|
273
+ @by_key[[:aggregate, section.name]] = section if section.aggregate
274
+ section.terms.each { |entry| @by_key[key_of(entry)] = entry }
275
+ end
276
+ end
277
+
278
+ def key_of(entry)
279
+ case entry.kind
280
+ when :command, :query, :value_object then [entry.kind, entry.within, entry.name]
281
+ else [entry.kind, entry.name]
282
+ end
283
+ end
360
284
 
361
- def section(title, rows)
362
- return nil unless rows
285
+ def [](kind, name, within: nil)
286
+ @by_key[within ? [kind, within, name] : [kind, name]]
287
+ end
363
288
 
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
289
+ # A Markdown link to a term's own heading, or the plain words when
290
+ # the chapter declares no such term. `label:` overrides the link
291
+ # text for a sentence that has to tell two same-named terms apart.
292
+ def link(kind, name, within: nil, label: nil)
293
+ target = self[kind, name, within: within]
294
+ return label || Naming.words(name) unless target
367
295
 
368
- def entry_row(entry)
369
- term = entry.within ? "**#{entry.term}** *(#{entry.within})*" : "**#{entry.term}**"
370
- "| #{term} | #{entry.kind} | #{entry.definition || '—'} |"
296
+ text = label || (target.is_a?(Section) ? target.title : target.headword)
297
+ "[#{text}](##{target.slug})"
298
+ end
371
299
  end
372
300
  end
373
301
  end
@@ -284,18 +284,11 @@ module Hecks
284
284
 
285
285
  # ── small sentence carpentry ──────────────────────────────────────
286
286
 
287
- def to_sentence_list(items, conj: "and")
288
- case items.size
289
- when 0 then ""
290
- when 1 then items[0].to_s
291
- when 2 then "#{items[0]} #{conj} #{items[1]}"
292
- else "#{items[0..-2].join(', ')}, #{conj} #{items[-1]}"
293
- end
294
- end
287
+ # Both now live in `Naming` (a second projection, the glossary,
288
+ # needed them); kept here as names so this file reads as it did.
289
+ def to_sentence_list(items, conj: "and") = Naming.to_sentence_list(items, conj: conj)
295
290
 
296
- def a_or_an(word)
297
- %w[a e i o u].include?(word.to_s[0].to_s.downcase) ? "an" : "a"
298
- end
291
+ def a_or_an(word) = Naming.a_or_an(word)
299
292
  end
300
293
  end
301
294
  end