hecks 1.1.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/outbox.rb +7 -4
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
- data/lib/hecks/adapters/driven/postgres.rb +20 -17
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/assembly/marks.rb +13 -3
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
- 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/append_only.rb +11 -0
- 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 +85 -5
- 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 +302 -0
- data/lib/hecks/projections.rb +1 -0
- data/lib/hecks/projector/cli_projector.rb +29 -13
- data/lib/hecks/projector/exporter.rb +24 -0
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
- data/lib/hecks/query_specification/common/order_by.rb +9 -2
- data/lib/hecks/query_specification/common/where_clause.rb +15 -2
- 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/interpreting.rb +16 -7
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
- 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 -52
- 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 +25 -2
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
require_relative "../../forms/html"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Glossary
|
|
6
|
+
# THE PAGE, RENDERED FROM THE MARKDOWN — `html/index.html` is
|
|
7
|
+
# `glossary.md` read back and dressed: a navigation rail built from
|
|
8
|
+
# its `##` headings, each `###` a term entry, every ```mermaid fence
|
|
9
|
+
# a diagram, every `[x](#y)` an in-page link. Nothing here reads
|
|
10
|
+
# the bluebook; if the page shows it, the Markdown says it, so the
|
|
11
|
+
# two cannot drift.
|
|
12
|
+
#
|
|
13
|
+
# A SUBSET RENDERER, NOT A MARKDOWN LIBRARY — the projector emits
|
|
14
|
+
# a fixed handful of constructs (`Markdown`'s own header lists
|
|
15
|
+
# them), and this reads exactly those. The Gemfile keeps every
|
|
16
|
+
# dependency justified in its own comment; a full Markdown engine
|
|
17
|
+
# for six constructs would not earn one.
|
|
18
|
+
#
|
|
19
|
+
# The look lives in page.css / page.js beside this file — read at
|
|
20
|
+
# render time and inlined, so the page is one self-contained file.
|
|
21
|
+
# The only things it fetches are the two typefaces (Google Fonts)
|
|
22
|
+
# and Mermaid itself (cdnjs), which draws the diagrams in the
|
|
23
|
+
# browser; without a network the diagrams show as their source.
|
|
24
|
+
module Html
|
|
25
|
+
Block = Struct.new(:type, :text, :items, :level, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
FONTS = "https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;" \
|
|
28
|
+
"0,6..72,600;1,6..72,400&family=Instrument+Sans:wght@400;500;600&display=swap".freeze
|
|
29
|
+
MERMAID = "https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js".freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def render(markdown)
|
|
34
|
+
blocks = parse(markdown)
|
|
35
|
+
slugs = heading_slugs(blocks)
|
|
36
|
+
title = blocks.find { |block| block.type == :heading && block.level == 1 }&.text.to_s
|
|
37
|
+
sections = split_sections(blocks)
|
|
38
|
+
page(title, sections, slugs)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ── reading the Markdown ─────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def parse(markdown)
|
|
44
|
+
lines = markdown.lines.map(&:chomp)
|
|
45
|
+
blocks = []
|
|
46
|
+
index = 0
|
|
47
|
+
while index < lines.size
|
|
48
|
+
line = lines[index]
|
|
49
|
+
if line.start_with?("```mermaid")
|
|
50
|
+
block, index = fence(lines, index)
|
|
51
|
+
blocks << block
|
|
52
|
+
elsif (heading = line.match(/\A(\#{1,3}) (.+)\z/))
|
|
53
|
+
blocks << Block.new(type: :heading, text: heading[2], level: heading[1].size)
|
|
54
|
+
index += 1
|
|
55
|
+
elsif line.start_with?("> ")
|
|
56
|
+
blocks << Block.new(type: :quote, text: line[2..])
|
|
57
|
+
index += 1
|
|
58
|
+
elsif line.start_with?("- ")
|
|
59
|
+
items, index = run(lines, index) { |text| text.start_with?("- ") }
|
|
60
|
+
blocks << Block.new(type: :list, items: items.map { |item| item[2..] })
|
|
61
|
+
elsif line.strip.empty?
|
|
62
|
+
index += 1
|
|
63
|
+
else
|
|
64
|
+
para, index = run(lines, index) { |text| !text.strip.empty? }
|
|
65
|
+
blocks << Block.new(type: :paragraph, text: para.join(" "))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
blocks
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The mermaid source between the opening fence at `index` and its
|
|
72
|
+
# closing one, and the index just past that.
|
|
73
|
+
def fence(lines, index)
|
|
74
|
+
close = ((index + 1)...lines.size).find { |at| lines[at] == "```" } || lines.size
|
|
75
|
+
[Block.new(type: :mermaid, text: lines[(index + 1)...close].join("\n")), close + 1]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The consecutive lines from `index` that satisfy the block, and
|
|
79
|
+
# the index just past them.
|
|
80
|
+
def run(lines, index)
|
|
81
|
+
taken = []
|
|
82
|
+
while index < lines.size && yield(lines[index])
|
|
83
|
+
taken << lines[index]
|
|
84
|
+
index += 1
|
|
85
|
+
end
|
|
86
|
+
[taken, index]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The same slugs GitHub would give these headings, in the same
|
|
90
|
+
# order — so the links the Markdown carries land here too.
|
|
91
|
+
#
|
|
92
|
+
# KEYED BY THE BLOCK ITSELF, NOT ITS VALUE — a Struct compares by
|
|
93
|
+
# members, so two "### Open" headings in different sections would
|
|
94
|
+
# otherwise be one key, and the first would answer with the
|
|
95
|
+
# second's "-1" slug, leaving `#open` with nothing to land on.
|
|
96
|
+
def heading_slugs(blocks)
|
|
97
|
+
seen = Hash.new(0)
|
|
98
|
+
slugs = {}.compare_by_identity
|
|
99
|
+
blocks.select { |block| block.type == :heading }.each do |block|
|
|
100
|
+
base = Slugs.github(block.text)
|
|
101
|
+
seen[base] += 1
|
|
102
|
+
slugs[block] = seen[base] == 1 ? base : "#{base}-#{seen[base] - 1}"
|
|
103
|
+
end
|
|
104
|
+
slugs
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Everything before the first `##` is the front matter; each `##`
|
|
108
|
+
# opens a section that runs to the next.
|
|
109
|
+
def split_sections(blocks)
|
|
110
|
+
sections = [{ heading: nil, blocks: [] }]
|
|
111
|
+
blocks.each do |block|
|
|
112
|
+
sections << { heading: block, blocks: [] } if block.type == :heading && block.level == 2
|
|
113
|
+
sections.last[:blocks] << block
|
|
114
|
+
end
|
|
115
|
+
sections
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ── writing the page ─────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def page(title, sections, slugs)
|
|
121
|
+
front, *rest = sections
|
|
122
|
+
<<~HTML
|
|
123
|
+
<!doctype html>
|
|
124
|
+
<html lang="en">
|
|
125
|
+
<head>
|
|
126
|
+
<meta charset="utf-8">
|
|
127
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
128
|
+
<title>#{escape(title)}</title>
|
|
129
|
+
<link rel="stylesheet" href="#{FONTS}">
|
|
130
|
+
<script src="#{MERMAID}"></script>
|
|
131
|
+
<style>
|
|
132
|
+
#{asset('page.css')}
|
|
133
|
+
</style>
|
|
134
|
+
</head>
|
|
135
|
+
<body>
|
|
136
|
+
<div class="page">
|
|
137
|
+
#{rail(title, rest, slugs)}
|
|
138
|
+
<main>
|
|
139
|
+
#{front_matter(front[:blocks])}
|
|
140
|
+
#{rest.map { |section| section_html(section, slugs) }.join("\n")}
|
|
141
|
+
</main>
|
|
142
|
+
</div>
|
|
143
|
+
<script>
|
|
144
|
+
#{asset('page.js')}
|
|
145
|
+
</script>
|
|
146
|
+
</body>
|
|
147
|
+
</html>
|
|
148
|
+
HTML
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def rail(title, sections, slugs)
|
|
152
|
+
domain = title.split(" — ").first
|
|
153
|
+
items = sections.map do |section|
|
|
154
|
+
heading = section[:heading]
|
|
155
|
+
%(<li><a href="##{slugs[heading]}">#{inline(heading.text)}</a></li>)
|
|
156
|
+
end
|
|
157
|
+
<<~HTML
|
|
158
|
+
<nav class="rail" aria-label="Sections">
|
|
159
|
+
<p class="domain">#{escape(domain)}</p>
|
|
160
|
+
<p class="sub">Glossary</p>
|
|
161
|
+
<input type="search" placeholder="Find a term" aria-label="Find a term" autocomplete="off">
|
|
162
|
+
<p class="count"></p>
|
|
163
|
+
<ol>
|
|
164
|
+
#{items.join("\n")}
|
|
165
|
+
</ol>
|
|
166
|
+
</nav>
|
|
167
|
+
HTML
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def front_matter(blocks)
|
|
171
|
+
parts = ["<header>"]
|
|
172
|
+
blocks.each do |block|
|
|
173
|
+
case block.type
|
|
174
|
+
when :heading then parts << "<h1>#{inline(block.text.split(' — ').first)}</h1>"
|
|
175
|
+
when :quote then parts << "<p class=\"vision\">#{inline(block.text)}</p>"
|
|
176
|
+
when :paragraph then parts << "<p class=\"lede\">#{inline(block.text)}</p>"
|
|
177
|
+
when :mermaid then parts << figure(block, "How it all fits together")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
parts << "</header>"
|
|
181
|
+
parts.join("\n")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A section is its heading, then its opening (lede, diagrams, the
|
|
185
|
+
# rules list), then its terms — each `###` opens an article that
|
|
186
|
+
# runs to the next. A bold-only paragraph is the caption of
|
|
187
|
+
# whatever figure or list follows it.
|
|
188
|
+
def section_html(section, slugs)
|
|
189
|
+
heading = section[:heading]
|
|
190
|
+
state = { parts: [%(<section id="#{slugs[heading]}">), "<h2>#{inline(heading.text)}</h2>"],
|
|
191
|
+
caption: nil, in_terms: false }
|
|
192
|
+
section[:blocks].drop(1).each { |block| section_block(block, state, slugs) }
|
|
193
|
+
state[:parts] << "</article></div>" if state[:in_terms]
|
|
194
|
+
state[:parts] << "</section>"
|
|
195
|
+
state[:parts].join("\n")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def section_block(block, state, slugs)
|
|
199
|
+
parts = state[:parts]
|
|
200
|
+
case block.type
|
|
201
|
+
when :heading
|
|
202
|
+
parts << (state[:in_terms] ? "</article>" : "<div class=\"terms\">")
|
|
203
|
+
parts << %(<article class="term" id="#{slugs[block]}">) << "<h3>#{inline(block.text)}</h3>"
|
|
204
|
+
state[:in_terms] = true
|
|
205
|
+
when :mermaid
|
|
206
|
+
parts << figure(block, state.delete(:caption))
|
|
207
|
+
when :list
|
|
208
|
+
parts << "<p class=\"caption\">#{escape(state[:caption])}</p>" if state.delete(:caption)
|
|
209
|
+
parts << "<ul class=\"rules\">#{block.items.map { |item| "<li>#{inline(item)}</li>" }.join}</ul>"
|
|
210
|
+
when :quote
|
|
211
|
+
parts << "<p class=\"about\">#{inline(block.text)}</p>"
|
|
212
|
+
when :paragraph
|
|
213
|
+
paragraph(block.text, state)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def paragraph(text, state)
|
|
218
|
+
if text.match?(/\A\*\*[^*]+\*\*\z/)
|
|
219
|
+
state[:caption] = text.delete("*")
|
|
220
|
+
else
|
|
221
|
+
klass = text.start_with?("Always true:") ? " class=\"rule\"" : ""
|
|
222
|
+
state[:parts] << "<p#{klass}>#{inline(text)}</p>"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def figure(block, caption)
|
|
227
|
+
parts = ["<figure>"]
|
|
228
|
+
parts << "<figcaption>#{escape(caption)}</figcaption>" if caption
|
|
229
|
+
parts << "<pre class=\"mermaid\">#{escape(block.text)}</pre>" << "</figure>"
|
|
230
|
+
parts.join("\n")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# ── inline text ──────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
def escape(text) = Forms::Escape.html(text)
|
|
236
|
+
|
|
237
|
+
# Escape first, so nothing in the prose ever becomes a tag; then
|
|
238
|
+
# the three inline forms the Markdown uses, on the escaped text.
|
|
239
|
+
def inline(text)
|
|
240
|
+
escape(text)
|
|
241
|
+
.gsub(/\[([^\]]+)\]\(#([^)]+)\)/) { %(<a href="##{Regexp.last_match(2)}">#{Regexp.last_match(1)}</a>) }
|
|
242
|
+
.gsub(/\*\*(.+?)\*\*/) { "<strong>#{Regexp.last_match(1)}</strong>" }
|
|
243
|
+
.gsub(/\*(.+?)\*/) { "<em>#{Regexp.last_match(1)}</em>" }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def asset(name) = File.read(File.join(__dir__, name))
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require_relative "sections"
|
|
2
|
+
require_relative "sentences"
|
|
3
|
+
require_relative "mermaid"
|
|
4
|
+
require_relative "../statements"
|
|
5
|
+
|
|
6
|
+
module Hecks
|
|
7
|
+
module Projections
|
|
8
|
+
module Glossary
|
|
9
|
+
# THE DOCUMENT ITSELF — `glossary.md`, the one source the page is
|
|
10
|
+
# rendered from. Written in the handful of Markdown constructs
|
|
11
|
+
# GitHub renders as-is (headings, blockquotes, ```mermaid fences,
|
|
12
|
+
# lists, in-page links), so the file is a complete, readable
|
|
13
|
+
# glossary on its own before any page is built from it:
|
|
14
|
+
#
|
|
15
|
+
# # Banking — Glossary
|
|
16
|
+
# > vision
|
|
17
|
+
# lede
|
|
18
|
+
# ```mermaid the map
|
|
19
|
+
# ## Account
|
|
20
|
+
# > description
|
|
21
|
+
# Starts out open. Can be open, frozen, or closed.
|
|
22
|
+
# **How it fits** ```mermaid
|
|
23
|
+
# **How it moves** ```mermaid
|
|
24
|
+
# **Always true** - one sentence per declared fact
|
|
25
|
+
# ### Account number one ### per term, A to Z
|
|
26
|
+
# Made up of a value (text).
|
|
27
|
+
# Always true: an account number is present.
|
|
28
|
+
module Markdown
|
|
29
|
+
LEDE = "Every term %<name>s uses, in the words of the people who work in it — grouped under the thing " \
|
|
30
|
+
"each belongs to, and listed A to Z within it. This page is generated from the working " \
|
|
31
|
+
"specification, so it says what the system does today, not what anyone hoped it would do. " \
|
|
32
|
+
"If a sentence here reads wrong to you, the specification is wrong: say so.".freeze
|
|
33
|
+
|
|
34
|
+
STANDING = {
|
|
35
|
+
ROLES => "Who does what. A role is named once here rather than under every term it touches.",
|
|
36
|
+
READ_MODELS => "Questions answered across more than one of the things above.",
|
|
37
|
+
REACTIONS => "What happens on its own, in response to something this specification does not itself raise."
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
def title(bluebook) = "#{bluebook.name} — Glossary"
|
|
43
|
+
|
|
44
|
+
def render(document)
|
|
45
|
+
parts = [header(document.bluebook)]
|
|
46
|
+
parts += document.sections.map { |section| section_text(section, document) }
|
|
47
|
+
"#{parts.join("\n\n")}\n"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def header(bluebook)
|
|
51
|
+
parts = ["# #{title(bluebook)}"]
|
|
52
|
+
parts << "> #{bluebook.vision}" if bluebook.vision
|
|
53
|
+
parts << format(LEDE, name: bluebook.name)
|
|
54
|
+
parts << fence(Mermaid.map(bluebook))
|
|
55
|
+
parts.join("\n\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def section_text(section, document)
|
|
59
|
+
parts = ["## #{section.title}"]
|
|
60
|
+
parts += section.aggregate ? opening(section.aggregate, document.bluebook) : ["> #{STANDING[section.name]}"]
|
|
61
|
+
parts += section.terms.map { |entry| term_text(entry, document.index) }
|
|
62
|
+
parts.join("\n\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# What an aggregate IS, what it can be, how it fits and moves,
|
|
66
|
+
# and what is always true of it — before a single term.
|
|
67
|
+
def opening(aggregate, bluebook)
|
|
68
|
+
parts = []
|
|
69
|
+
parts << "> #{aggregate.description}" if aggregate.description
|
|
70
|
+
parts << Sentences.lifecycle_sentence(aggregate.lifecycle) if aggregate.lifecycle
|
|
71
|
+
parts << "**How it fits**" << fence(Mermaid.context(aggregate, bluebook))
|
|
72
|
+
parts << "**How it moves**" << fence(Mermaid.lifecycle(aggregate)) if aggregate.lifecycle
|
|
73
|
+
statements = always_true(aggregate, bluebook)
|
|
74
|
+
parts << "**Always true**" << statements.map { |statement| "- #{statement}" }.join("\n") unless statements.empty?
|
|
75
|
+
parts
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# THE STATEMENTS PROJECTION'S OWN SENTENCES, SPOKEN. An invariant
|
|
79
|
+
# is the author's words and stays exactly so. A relationship
|
|
80
|
+
# sentence is built mechanically from construct names ("An
|
|
81
|
+
# ATMCard references an Account."), and a construct name is the
|
|
82
|
+
# one thing this page never shows as an identifier — so each is
|
|
83
|
+
# replaced with how it is said, whole-word, and nothing else in
|
|
84
|
+
# the sentence is touched.
|
|
85
|
+
def always_true(aggregate, bluebook)
|
|
86
|
+
names = bluebook.aggregates.flat_map { |other| [other.hecks_name, *other.entities.map(&:hecks_name)] }
|
|
87
|
+
[aggregate, *aggregate.entities].flat_map do |holder|
|
|
88
|
+
Statements.attribute_statements(holder).map { |sentence| spoken(sentence, names) } +
|
|
89
|
+
Statements.invariant_statements(holder)
|
|
90
|
+
end.uniq
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def spoken(sentence, names)
|
|
94
|
+
names.reduce(sentence) { |text, name| text.gsub(/\b#{Regexp.escape(name)}\b/, Naming.words(name)) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def term_text(entry, index)
|
|
98
|
+
["### #{entry.headword}", *Sentences.paragraphs(entry, index)].join("\n\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fence(source) = "```mermaid\n#{source}\n```"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require_relative "../../naming"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Glossary
|
|
6
|
+
# THE THREE PICTURES THE GLOSSARY DRAWS, each kept to the handful of
|
|
7
|
+
# things that matter — Evans' "three to five objects central to the
|
|
8
|
+
# issue at hand", never the whole model at once.
|
|
9
|
+
#
|
|
10
|
+
# map the aggregates and which points at which — one small
|
|
11
|
+
# graph at the top, for the reader who wants the shape
|
|
12
|
+
# of the domain before its words.
|
|
13
|
+
# context one aggregate, its entities, and the neighbours it
|
|
14
|
+
# points at or is pointed at by. Value objects are
|
|
15
|
+
# words in the text ("Made up of …"), not boxes here —
|
|
16
|
+
# Account alone has eight, and eight boxes say less
|
|
17
|
+
# than one sentence.
|
|
18
|
+
# lifecycle the states a thing can be in and what moves it.
|
|
19
|
+
#
|
|
20
|
+
# Every label is a person's spelling (`Naming.words`), and the
|
|
21
|
+
# edges read from the same declared facts `Projections::Diagrams`
|
|
22
|
+
# draws its own from (`reference?`, `type.target_name`, `name`,
|
|
23
|
+
# `lifecycle.transitions`) — a different picture of the same
|
|
24
|
+
# declaration, not a second declaration.
|
|
25
|
+
module Mermaid
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def map(bluebook)
|
|
29
|
+
names = bluebook.aggregates.map(&:hecks_name)
|
|
30
|
+
lines = ["flowchart LR"]
|
|
31
|
+
names.each { |name| lines << node(name) }
|
|
32
|
+
bluebook.aggregates.each do |aggregate|
|
|
33
|
+
references(aggregate).each do |attribute|
|
|
34
|
+
target = attribute.type.target_name
|
|
35
|
+
lines << node(target) unless names.include?(target) || lines.include?(node(target))
|
|
36
|
+
lines << edge(aggregate.hecks_name, attribute, target)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
lines.join("\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def context(aggregate, bluebook)
|
|
43
|
+
focus = aggregate.hecks_name
|
|
44
|
+
lines = ["flowchart LR", node(focus, focus: true)]
|
|
45
|
+
entities(aggregate).each do |attribute|
|
|
46
|
+
lines << node(attribute.type.to_s)
|
|
47
|
+
lines << edge(focus, attribute, attribute.type.to_s)
|
|
48
|
+
end
|
|
49
|
+
references(aggregate).each do |attribute|
|
|
50
|
+
lines << node(attribute.type.target_name)
|
|
51
|
+
lines << edge(focus, attribute, attribute.type.target_name)
|
|
52
|
+
end
|
|
53
|
+
pointing_at(aggregate, bluebook).each do |holder, attribute|
|
|
54
|
+
lines << node(holder.hecks_name)
|
|
55
|
+
lines << edge(holder.hecks_name, attribute, focus)
|
|
56
|
+
end
|
|
57
|
+
lines << " classDef focus stroke-width:3px"
|
|
58
|
+
lines.uniq.join("\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def lifecycle(holder)
|
|
62
|
+
lifecycle = holder.lifecycle
|
|
63
|
+
lines = ["stateDiagram-v2"]
|
|
64
|
+
states = ([lifecycle.default] + lifecycle.transitions.map { |_name, transition| transition.target }).uniq
|
|
65
|
+
states.select { |state| state.to_s.include?("_") }.each do |state|
|
|
66
|
+
lines << " state \"#{state.to_s.tr('_', ' ')}\" as #{state}"
|
|
67
|
+
end
|
|
68
|
+
lines << " [*] --> #{lifecycle.default}"
|
|
69
|
+
lifecycle.transitions.each do |name, transition|
|
|
70
|
+
Array(transition.from).each { |from| lines << " #{from} --> #{transition.target}: #{Naming.words(name)}" }
|
|
71
|
+
end
|
|
72
|
+
lines.join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# ── the facts drawn ──────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def references(holder) = holder.attributes.select(&:reference?)
|
|
78
|
+
|
|
79
|
+
# An entity is held as `list_of(LedgerEntry)` — the attribute
|
|
80
|
+
# whose element type names one of the aggregate's own entities.
|
|
81
|
+
def entities(aggregate)
|
|
82
|
+
names = aggregate.entities.map(&:hecks_name)
|
|
83
|
+
aggregate.attributes.select { |attribute| !attribute.reference? && names.include?(attribute.type.to_s) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def pointing_at(aggregate, bluebook)
|
|
87
|
+
bluebook.aggregates.reject { |other| other.equal?(aggregate) }.flat_map do |other|
|
|
88
|
+
references(other).select { |attribute| attribute.type.target_name == aggregate.hecks_name }
|
|
89
|
+
.map { |attribute| [other, attribute] }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ── the drawing ──────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
# A node id the page source can carry without an identifier in
|
|
96
|
+
# it — `n_atm_card`, never `n_ATMCard`.
|
|
97
|
+
def id(name) = "n_#{Naming.snake(name).gsub(/[^a-z0-9_]/, '_')}"
|
|
98
|
+
|
|
99
|
+
def node(name, focus: false)
|
|
100
|
+
line = " #{id(name)}[\"#{Naming.words(name)}\"]"
|
|
101
|
+
focus ? "#{line}:::focus" : line
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def edge(from, attribute, to)
|
|
105
|
+
" #{id(from)} -->|\"#{Naming.words(attribute.name).downcase}\"| #{id(to)}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|