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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
- data/lib/hecks/bluebook/model_check.rb +148 -17
- data/lib/hecks/forms/field_shape.rb +5 -3
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
- data/lib/hecks/fuzzing/era_boundary.rb +105 -0
- data/lib/hecks/fuzzing/form_census.rb +184 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
- data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
- data/lib/hecks/fuzzing/properties/guards.rb +103 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties.rb +23 -2
- data/lib/hecks/fuzzing/replay.rb +130 -20
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/self_consistency.rb +647 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
- data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
- data/lib/hecks/fuzzing/structural_skips.rb +146 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
- data/lib/hecks/fuzzing/value_generator.rb +55 -3
- data/lib/hecks/fuzzing.rb +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
- data/lib/hecks/naming.rb +70 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +214 -286
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
- data/lib/hecks/runtime/command_rules/references.rb +75 -0
- data/lib/hecks/runtime/entity_element.rb +168 -16
- data/lib/hecks/runtime/entity_interpreter.rb +68 -3
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/refusal_wording.rb +5 -2
- data/lib/hecks/runtime/registry.rb +12 -0
- data/lib/hecks/runtime/routing.rb +67 -2
- data/lib/hecks/runtime/saga_interpreter.rb +38 -1
- data/lib/hecks/runtime/value/coercion.rb +77 -115
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +7 -2
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- 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 —
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
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
|
-
|
|
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: {})
|
|
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
|
-
|
|
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
|
-
#
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
#
|
|
72
|
-
#
|
|
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
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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 +=
|
|
103
|
-
entries +=
|
|
104
|
-
entries +=
|
|
105
|
-
entries +=
|
|
106
|
-
entries +=
|
|
107
|
-
entries +=
|
|
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 +=
|
|
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
|
-
|
|
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(
|
|
139
|
-
section:
|
|
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
|
-
#
|
|
145
|
-
#
|
|
146
|
-
|
|
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 |
|
|
159
|
-
Entry.new(
|
|
160
|
-
section:
|
|
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
|
-
|
|
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(
|
|
202
|
-
section:
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
#
|
|
264
|
-
#
|
|
265
|
-
#
|
|
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 { |
|
|
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
|
|
222
|
+
holder.commands.select(&:role).each { |command| by_role[command.role] << [holder, command] }
|
|
273
223
|
end
|
|
274
|
-
by_role.map do |role,
|
|
275
|
-
Entry.new(
|
|
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
|
|
296
|
-
#
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
|
|
285
|
+
def [](kind, name, within: nil)
|
|
286
|
+
@by_key[within ? [kind, within, name] : [kind, name]]
|
|
287
|
+
end
|
|
363
288
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|