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.
Files changed (85) 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/outbox.rb +7 -4
  4. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  5. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
  6. data/lib/hecks/adapters/driven/postgres.rb +20 -17
  7. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
  9. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  10. data/lib/hecks/behaviors/expectations.rb +32 -4
  11. data/lib/hecks/bluebook/assembly/marks.rb +13 -3
  12. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  13. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  14. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
  15. data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
  16. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  17. data/lib/hecks/bluebook/model_check.rb +148 -17
  18. data/lib/hecks/forms/field_shape.rb +5 -3
  19. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  20. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  21. data/lib/hecks/fuzzing/form_census.rb +184 -0
  22. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  23. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  24. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  25. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  26. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  27. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  28. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  29. data/lib/hecks/fuzzing/properties.rb +23 -2
  30. data/lib/hecks/fuzzing/replay.rb +130 -20
  31. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  32. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  33. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  34. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  35. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  36. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  37. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  38. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  39. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  40. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  41. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  42. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  43. data/lib/hecks/fuzzing.rb +6 -0
  44. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  45. data/lib/hecks/naming.rb +70 -2
  46. data/lib/hecks/ports/persistence/append_only.rb +11 -0
  47. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  48. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  49. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +85 -5
  50. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  51. data/lib/hecks/projections/glossary/html.rb +250 -0
  52. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  53. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  54. data/lib/hecks/projections/glossary/page.css +271 -0
  55. data/lib/hecks/projections/glossary/page.js +72 -0
  56. data/lib/hecks/projections/glossary/sections.rb +17 -0
  57. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  58. data/lib/hecks/projections/glossary.rb +302 -0
  59. data/lib/hecks/projections.rb +1 -0
  60. data/lib/hecks/projector/cli_projector.rb +29 -13
  61. data/lib/hecks/projector/exporter.rb +24 -0
  62. data/lib/hecks/projector/narrate_projector.rb +4 -11
  63. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  64. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  65. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  66. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  67. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  68. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  69. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  70. data/lib/hecks/runtime/entity_element.rb +168 -16
  71. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  72. data/lib/hecks/runtime/interpreting.rb +16 -7
  73. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  74. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  75. data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
  76. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  77. data/lib/hecks/runtime/registry.rb +12 -0
  78. data/lib/hecks/runtime/routing.rb +67 -2
  79. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  80. data/lib/hecks/runtime/value/coercion.rb +77 -52
  81. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  82. data/lib/hecks/runtime/value.rb +7 -2
  83. data/lib/hecks/version.rb +1 -1
  84. data/lib/hecks/vocabulary.rb +2 -1
  85. metadata +25 -2
@@ -0,0 +1,271 @@
1
+ /* The glossary page. Ledger paper: faint blue horizontal rules between
2
+ entries, one red vertical margin rule down the navigation. The rules
3
+ carry information — the red marks the section in view, the blue
4
+ separates entries — and nothing else on the page is decoration. */
5
+
6
+ :root {
7
+ --paper: #f3f5f8;
8
+ --panel: #ffffff;
9
+ --ink: #1a2b4a;
10
+ --ink-soft: #4a5873;
11
+ --ink-faint: #8592a8;
12
+ --rule: #c9d3e3;
13
+ --red: #b0413e;
14
+ --red-wash: rgba(176, 65, 62, 0.09);
15
+ }
16
+
17
+ @media (prefers-color-scheme: dark) {
18
+ :root:not([data-theme="light"]) {
19
+ --paper: #0f1726;
20
+ --panel: #141e33;
21
+ --ink: #e3e7ee;
22
+ --ink-soft: #b4bdd0;
23
+ --ink-faint: #7f8ba6;
24
+ --rule: #2a3854;
25
+ --red: #d9635f;
26
+ --red-wash: rgba(217, 99, 95, 0.14);
27
+ }
28
+ }
29
+
30
+ :root[data-theme="dark"] {
31
+ --paper: #0f1726;
32
+ --panel: #141e33;
33
+ --ink: #e3e7ee;
34
+ --ink-soft: #b4bdd0;
35
+ --ink-faint: #7f8ba6;
36
+ --rule: #2a3854;
37
+ --red: #d9635f;
38
+ --red-wash: rgba(217, 99, 95, 0.14);
39
+ }
40
+
41
+ html { color-scheme: light dark; scroll-behavior: smooth; }
42
+
43
+ body {
44
+ margin: 0;
45
+ background: var(--paper);
46
+ color: var(--ink);
47
+ font-family: "Newsreader", Georgia, "Times New Roman", serif;
48
+ font-size: 17px;
49
+ line-height: 1.55;
50
+ font-variant-numeric: oldstyle-nums;
51
+ }
52
+
53
+ a { color: var(--red); }
54
+ :focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
55
+
56
+ .page {
57
+ display: grid;
58
+ grid-template-columns: 15.5rem minmax(0, 1fr);
59
+ min-height: 100vh;
60
+ }
61
+
62
+ /* ── the rail ───────────────────────────────────────────────────── */
63
+
64
+ .rail {
65
+ position: sticky;
66
+ top: 0;
67
+ height: 100vh;
68
+ box-sizing: border-box;
69
+ overflow-y: auto;
70
+ padding: 2.4rem 1.3rem 2rem 1.6rem;
71
+ border-right: 1px solid var(--rule);
72
+ font-family: "Instrument Sans", system-ui, -apple-system, sans-serif;
73
+ font-size: 0.92rem;
74
+ }
75
+
76
+ .rail .domain {
77
+ font-family: "Newsreader", Georgia, serif;
78
+ font-size: 1.55rem;
79
+ font-weight: 600;
80
+ line-height: 1.1;
81
+ margin: 0;
82
+ }
83
+
84
+ .rail .sub { color: var(--ink-faint); margin: 0.15rem 0 1.4rem; font-size: 0.82rem; }
85
+
86
+ .rail input {
87
+ width: 100%;
88
+ box-sizing: border-box;
89
+ font: inherit;
90
+ padding: 0.45rem 0.65rem;
91
+ border: 1px solid var(--rule);
92
+ border-radius: 4px;
93
+ background: var(--panel);
94
+ color: var(--ink);
95
+ }
96
+
97
+ .rail input::placeholder { color: var(--ink-faint); }
98
+
99
+ .rail .count { color: var(--ink-faint); font-size: 0.78rem; margin: 0.45rem 0 1.2rem; }
100
+
101
+ .rail ol {
102
+ list-style: none;
103
+ margin: 0;
104
+ padding: 0;
105
+ border-left: 2px solid var(--red);
106
+ }
107
+
108
+ .rail li a {
109
+ display: block;
110
+ padding: 0.28rem 0.85rem;
111
+ margin-left: -2px;
112
+ border-left: 2px solid transparent;
113
+ color: var(--ink-soft);
114
+ text-decoration: none;
115
+ }
116
+
117
+ .rail li a:hover { color: var(--ink); }
118
+
119
+ .rail li a[aria-current="true"] {
120
+ color: var(--ink);
121
+ font-weight: 600;
122
+ border-left-color: var(--ink);
123
+ }
124
+
125
+ /* ── the text column ────────────────────────────────────────────── */
126
+
127
+ main {
128
+ padding: 3.2rem 4rem 7rem 3.6rem;
129
+ max-width: 47rem;
130
+ }
131
+
132
+ header h1 {
133
+ font-size: 3rem;
134
+ font-weight: 500;
135
+ line-height: 1.02;
136
+ letter-spacing: -0.012em;
137
+ margin: 0 0 1.1rem;
138
+ }
139
+
140
+ .vision {
141
+ font-size: 1.28rem;
142
+ font-style: italic;
143
+ line-height: 1.45;
144
+ color: var(--ink-soft);
145
+ margin: 0 0 1.2rem;
146
+ }
147
+
148
+ .lede { color: var(--ink-soft); margin: 0 0 1.6rem; max-width: 62ch; }
149
+
150
+ figure { margin: 1.2rem 0 1.8rem; }
151
+
152
+ figcaption, .caption {
153
+ font-family: "Instrument Sans", system-ui, sans-serif;
154
+ font-size: 0.8rem;
155
+ color: var(--ink-faint);
156
+ margin: 0 0 0.4rem;
157
+ }
158
+
159
+ .caption { margin-top: 1.6rem; }
160
+
161
+ pre.mermaid {
162
+ margin: 0;
163
+ padding: 1rem 1.2rem;
164
+ background: var(--panel);
165
+ border: 1px solid var(--rule);
166
+ border-radius: 6px;
167
+ overflow-x: auto;
168
+ font-family: "Instrument Sans", system-ui, sans-serif;
169
+ font-size: 0.85rem;
170
+ line-height: 1.4;
171
+ color: var(--ink-soft);
172
+ }
173
+
174
+ pre.mermaid svg { display: block; max-width: 100%; height: auto; }
175
+
176
+ section { margin-top: 4.5rem; scroll-margin-top: 1.5rem; }
177
+
178
+ section h2 {
179
+ display: inline-block;
180
+ font-size: 2.1rem;
181
+ font-weight: 500;
182
+ line-height: 1.1;
183
+ margin: 0 0 0.5rem;
184
+ padding-bottom: 0.3rem;
185
+ border-bottom: 2px solid var(--red);
186
+ }
187
+
188
+ .about {
189
+ font-size: 1.18rem;
190
+ color: var(--ink-soft);
191
+ margin: 0.5rem 0 0.7rem;
192
+ max-width: 60ch;
193
+ }
194
+
195
+ section > p:not(.about):not(.caption) { margin: 0 0 0.8rem; max-width: 62ch; }
196
+
197
+ .rules {
198
+ list-style: none;
199
+ margin: 0 0 1.2rem;
200
+ padding: 0;
201
+ max-width: 62ch;
202
+ }
203
+
204
+ .rules li {
205
+ padding: 0.38rem 0;
206
+ border-bottom: 1px solid var(--rule);
207
+ }
208
+
209
+ .terms { margin-top: 1.8rem; border-top: 1px solid var(--rule); }
210
+
211
+ .term {
212
+ display: grid;
213
+ grid-template-columns: 12.5rem minmax(0, 1fr);
214
+ column-gap: 1.6rem;
215
+ padding: 0.9rem 0;
216
+ border-bottom: 1px solid var(--rule);
217
+ scroll-margin-top: 1.5rem;
218
+ }
219
+
220
+ .term h3 {
221
+ font-size: 1.08rem;
222
+ font-weight: 600;
223
+ line-height: 1.35;
224
+ margin: 0.05rem 0 0;
225
+ }
226
+
227
+ .term p { margin: 0 0 0.4rem; }
228
+ .term p:last-child { margin-bottom: 0; }
229
+
230
+ .term p.rule { color: var(--ink-soft); font-size: 0.95rem; }
231
+
232
+ .term a, .rules a {
233
+ color: var(--red);
234
+ font-style: italic;
235
+ text-decoration: underline;
236
+ text-decoration-thickness: 1px;
237
+ text-underline-offset: 2px;
238
+ }
239
+
240
+ /* A search narrows the page to the terms that match. */
241
+ section.searching figure,
242
+ section.searching .rules,
243
+ section.searching .caption,
244
+ section.searching .about,
245
+ section.searching > p { display: none; }
246
+
247
+ section.searching .terms { border-top: 0; margin-top: 0.8rem; }
248
+
249
+ /* Landing on a term from a link. */
250
+ .landed { animation: land 1.4s ease-out; }
251
+
252
+ @keyframes land {
253
+ from { background: var(--red-wash); }
254
+ to { background: transparent; }
255
+ }
256
+
257
+ @media (max-width: 760px) {
258
+ .page { grid-template-columns: 1fr; }
259
+ .rail { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--rule); }
260
+ .rail ol { display: flex; flex-wrap: wrap; gap: 0.2rem 0.6rem; border-left: 0; }
261
+ .rail li a { padding: 0.2rem 0.4rem; border-left: 0; }
262
+ .rail li a[aria-current="true"] { border-bottom: 2px solid var(--red); }
263
+ main { padding: 1.8rem 1.2rem 4rem; }
264
+ header h1 { font-size: 2.3rem; }
265
+ .term { grid-template-columns: 1fr; row-gap: 0.25rem; }
266
+ }
267
+
268
+ @media (prefers-reduced-motion: reduce) {
269
+ html { scroll-behavior: auto; }
270
+ .landed { animation: none; }
271
+ }
@@ -0,0 +1,72 @@
1
+ // The page's three behaviours: draw the diagrams, find a term, and
2
+ // keep the rail pointing at the section in view. Nothing here changes
3
+ // what the page says — that is all in the Markdown it was rendered from.
4
+ (function () {
5
+ var root = document.documentElement;
6
+ var dark = root.dataset.theme === "dark" ||
7
+ (root.dataset.theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
8
+
9
+ if (window.mermaid) {
10
+ mermaid.initialize({
11
+ startOnLoad: true,
12
+ theme: dark ? "dark" : "neutral",
13
+ fontFamily: "\"Instrument Sans\", system-ui, sans-serif",
14
+ flowchart: { curve: "basis", padding: 12 }
15
+ });
16
+ }
17
+
18
+ var input = document.querySelector(".rail input");
19
+ var count = document.querySelector(".rail .count");
20
+ var terms = Array.prototype.slice.call(document.querySelectorAll(".term"));
21
+ var sections = Array.prototype.slice.call(document.querySelectorAll("main section"));
22
+ var links = {};
23
+ Array.prototype.forEach.call(document.querySelectorAll(".rail a"), function (a) {
24
+ links[a.getAttribute("href").slice(1)] = a;
25
+ });
26
+
27
+ function filter() {
28
+ var query = input.value.trim().toLowerCase();
29
+ var shown = 0;
30
+ terms.forEach(function (term) {
31
+ var hit = !query || term.textContent.toLowerCase().indexOf(query) !== -1;
32
+ term.hidden = !hit;
33
+ if (hit) shown += 1;
34
+ });
35
+ sections.forEach(function (section) {
36
+ var any = Array.prototype.some.call(section.querySelectorAll(".term"), function (t) { return !t.hidden; });
37
+ section.hidden = !!query && !any;
38
+ section.classList.toggle("searching", !!query);
39
+ var link = links[section.id];
40
+ if (link) link.parentNode.hidden = section.hidden;
41
+ });
42
+ count.textContent = query ? shown + " of " + terms.length + " terms" : terms.length + " terms";
43
+ }
44
+
45
+ input.addEventListener("input", filter);
46
+ filter();
47
+
48
+ if ("IntersectionObserver" in window) {
49
+ var observer = new IntersectionObserver(function (entries) {
50
+ entries.forEach(function (entry) {
51
+ if (!entry.isIntersecting) return;
52
+ Object.keys(links).forEach(function (id) { links[id].removeAttribute("aria-current"); });
53
+ var link = links[entry.target.id];
54
+ if (link) link.setAttribute("aria-current", "true");
55
+ });
56
+ }, { rootMargin: "-8% 0px -82% 0px" });
57
+ sections.forEach(function (section) { observer.observe(section); });
58
+ }
59
+
60
+ // A link inside a definition lands on a term; a search in progress
61
+ // would hide it, so the search is cleared first.
62
+ document.addEventListener("click", function (event) {
63
+ var a = event.target.closest && event.target.closest("a[href^='#']");
64
+ if (!a) return;
65
+ var target = document.getElementById(a.getAttribute("href").slice(1));
66
+ if (!target) return;
67
+ if (input.value) { input.value = ""; filter(); }
68
+ target.classList.remove("landed");
69
+ void target.offsetWidth;
70
+ target.classList.add("landed");
71
+ });
72
+ })();
@@ -0,0 +1,17 @@
1
+ module Hecks
2
+ module Projections
3
+ module Glossary
4
+ # THE THREE SECTIONS NOTHING HOMES TO ONE AGGREGATE. In their own
5
+ # file because `Markdown` reads them and loads before the
6
+ # projector's own body does.
7
+ ROLES = "Roles".freeze
8
+ READ_MODELS = "Read models".freeze
9
+ # Where a term lands when the fact it hangs on is raised by nothing
10
+ # this chapter declares — pizzas' `PizzaPaymentReceived` comes in
11
+ # through a port, so the policy reacting to it homes to no
12
+ # aggregate. Shown, not dropped: a visible blank is how a reader
13
+ # learns the language has a gap there.
14
+ REACTIONS = "Reactions".freeze
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,205 @@
1
+ require_relative "../../naming"
2
+ require_relative "../statements"
3
+
4
+ module Hecks
5
+ module Projections
6
+ module Glossary
7
+ # EVERY SENTENCE THE GLOSSARY SAYS, AND THE RULE EACH ONE OBEYS.
8
+ #
9
+ # Authored text is verbatim: an aggregate's `description`, a
10
+ # command's `goal`, a query's `description`, an invariant's own
11
+ # words. Derived text is mechanical, from one declared fact, in a
12
+ # shape a reader outside engineering would say — "Recorded after
13
+ # Freeze account", "Made up of an amount (a whole number) and a
14
+ # currency (text)". Nothing is paraphrased and nothing is guessed:
15
+ # a fact with no honest plain phrasing gets no sentence (the
16
+ # `DocsProjector` rule — a wrong sentence is worse than none).
17
+ #
18
+ # Cross-references come only from declared structure (a command's
19
+ # `emits:`, a policy's `on`/`trigger`), resolved through the
20
+ # document's `Index`; free prose is never searched for words to
21
+ # link. A reference the chapter does not declare (a cross-domain
22
+ # command) is spoken as plain words.
23
+ module Sentences
24
+ TYPE_WORDS = {
25
+ "Integer" => "a whole number", "Float" => "a number", "String" => "text",
26
+ "Boolean" => "yes or no", "TrueClass" => "yes or no", "FalseClass" => "yes or no"
27
+ }.freeze
28
+
29
+ module_function
30
+
31
+ # The paragraphs under a term's headword — the definition first,
32
+ # then, when the term carries rules, one "Always true: …" line.
33
+ def paragraphs(entry, index)
34
+ facts = entry.facts
35
+ case entry.kind
36
+ when :entity then holder_paragraphs(facts[:entity])
37
+ when :value_object then value_object_paragraphs(facts[:value_object], index, entry.within)
38
+ when :command then [command_sentence(facts[:command])]
39
+ when :query then [facts[:query].description]
40
+ when :event then [event_sentence(facts, index)]
41
+ when :policy then [policy_sentence(facts[:policy], index)]
42
+ when :saga then [saga_sentence(facts[:saga], index)]
43
+ when :role then [role_sentence(facts[:commands], index)]
44
+ when :read_model then [facts[:read_model].description]
45
+ end.compact
46
+ end
47
+
48
+ # An aggregate's lede or an entity's entry: what it is, then the
49
+ # states it can be in.
50
+ def holder_paragraphs(holder)
51
+ [holder.description, holder.lifecycle && lifecycle_sentence(holder.lifecycle)].compact
52
+ end
53
+
54
+ def lifecycle_sentence(lifecycle)
55
+ states = ([lifecycle.default] + lifecycle.transitions.map { |_name, transition| transition.target }).uniq
56
+ "Starts out #{spoken(lifecycle.default)}. " \
57
+ "Can be #{Naming.to_sentence_list(states.map { |state| spoken(state) }, conj: 'or')}."
58
+ end
59
+
60
+ # A state name is spelled `awaiting_credit`; said, it is "awaiting credit".
61
+ def spoken(state) = state.to_s.tr("_", " ")
62
+
63
+ def value_object_paragraphs(value_object, index, within)
64
+ rules = value_object.invariants.map { |invariant| Statements.invariant_statement(invariant) }
65
+ [value_object_sentence(value_object, index, within), rules_line(rules)].compact
66
+ end
67
+
68
+ # A one-field object whose field is just "value" IS its type —
69
+ # "Text.", "A whole number." — the field name would add nothing.
70
+ def value_object_sentence(value_object, index, within)
71
+ return closed_set_sentence(value_object.members) if value_object.closed_set?
72
+ return "A marker with no details of its own." if value_object.attributes.empty?
73
+
74
+ only = value_object.attributes.first
75
+ if value_object.attributes.size == 1 && only.name.to_s == "value"
76
+ return "#{upper_first(type_words(only, index, within))}."
77
+ end
78
+
79
+ fields = value_object.attributes.map { |field| field_phrase(field, index, within) }
80
+ "Made up of #{Naming.to_sentence_list(fields)}."
81
+ end
82
+
83
+ # "amount (a whole number)" — the field as the author named it,
84
+ # then what kind of thing goes in it.
85
+ def field_phrase(field, index, within)
86
+ "#{Naming.words(field.name).downcase} (#{type_words(field, index, within)})"
87
+ end
88
+
89
+ def type_words(field, index, within)
90
+ type = field.type.to_s
91
+ inner = TYPE_WORDS[type] || index.link(:value_object, type, within: within)
92
+ field.list? ? "a list of #{inner}" : inner
93
+ end
94
+
95
+ # A CLOSED SET'S ROWS — a one-field set is its values; a set
96
+ # whose rows carry more (StatementFrequency's cadence plus a
97
+ # retention and a fee) leads with the first field and keeps the
98
+ # rest beside it, so no row loses what makes it distinct.
99
+ def closed_set_sentence(members)
100
+ if members.first && members.first.size > 1
101
+ rows = members.map do |row|
102
+ lead, *rest = row.map { |field, value| [field, value] }
103
+ "#{lead.last} (#{rest.map { |field, value| "#{Naming.words(field).downcase} #{value}" }.join(', ')})"
104
+ end
105
+ "One of: #{rows.join('; ')}."
106
+ else
107
+ "One of #{Naming.to_sentence_list(members.flat_map(&:values).uniq.map(&:to_s), conj: 'or')}."
108
+ end
109
+ end
110
+
111
+ # "Always true: an amount is positive; a currency is a three-letter
112
+ # code." — the rules as their author wrote them, kept out of the
113
+ # definition sentence (a rule hidden inside a definition is a rule
114
+ # a reader misses).
115
+ def rules_line(rules)
116
+ return nil if rules.empty?
117
+
118
+ clauses = rules.map { |rule| lower_first(rule.sub(/\.\z/, "")) }
119
+ "Always true: #{clauses.join('; ')}."
120
+ end
121
+
122
+ def command_sentence(command)
123
+ parts = []
124
+ parts << with_period(command.goal) if command.goal
125
+ parts << "Done by the #{Naming.words(command.role).downcase}." if command.role
126
+ parts.empty? ? nil : parts.join(" ")
127
+ end
128
+
129
+ def event_sentence(facts, index)
130
+ raisers = command_links(facts[:raised_by], index)
131
+ sentence = "Recorded after #{Naming.to_sentence_list(raisers, conj: 'or')}."
132
+ reactions = facts[:policies].map { |policy| index.link(:policy, policy.name) }
133
+ sentence += " Prompts #{Naming.to_sentence_list(reactions)}." unless reactions.empty?
134
+ sentence
135
+ end
136
+
137
+ # "When Customer suspended happens, Account is asked to Freeze
138
+ # account, once for each row of Open for customer." — a
139
+ # cross-domain trigger (`across "Compliance"`) is spoken as words
140
+ # with the domain named, since nothing here to link to exists.
141
+ def policy_sentence(policy, index)
142
+ holder, command = split_trigger(policy.trigger_command)
143
+ asked = if policy.target_domain
144
+ "#{Naming.words(holder)} is asked to #{Naming.words(command).downcase}, " \
145
+ "in #{Naming.words(policy.target_domain)}"
146
+ else
147
+ "#{index.link(:aggregate, holder)} is asked to #{index.link(:command, command, within: holder)}"
148
+ end
149
+ sentence = "When #{index.link(:event, bare(policy.on_event))} happens, #{asked}"
150
+ if policy.for_each
151
+ query_holder, query = split_trigger(policy.for_each)
152
+ sentence += ", once for each row of #{index.link(:query, query, within: query_holder)}"
153
+ end
154
+ "#{sentence}."
155
+ end
156
+
157
+ def saga_sentence(shape, index)
158
+ sentence = "Begins when #{index.link(:event, bare(shape[:starts_on]))} happens " \
159
+ "and ends when #{index.link(:event, bare(shape[:ends_on]))} happens."
160
+ states = Array(shape[:states]).map { |state| spoken(state) }
161
+ sentence += " Along the way it can be #{Naming.to_sentence_list(states, conj: 'or')}." unless states.empty?
162
+ sentence
163
+ end
164
+
165
+ # A NOUN LIST, DELIBERATELY — "Responsible for Credit and Debit",
166
+ # never "Can credit and debit": banking's System role raises
167
+ # `Debited` and `Credited`, and "can … debited" is a wrong
168
+ # sentence. The headwords are already what people say.
169
+ def role_sentence(issues, index)
170
+ "Responsible for #{Naming.to_sentence_list(command_links(issues, index))}."
171
+ end
172
+
173
+ # THE SAME WORD FOR TWO DIFFERENT THINGS gets its holder beside it
174
+ # — a role responsible for CardPayment's Reverse and Transfer's
175
+ # Reverse is responsible for "Reverse (card payment)" and
176
+ # "Reverse (transfer)", not for "Reverse" twice.
177
+ def command_links(issues, index)
178
+ issues = issues.uniq { |holder, command| [holder.hecks_name, command.hecks_name] }
179
+ repeated = issues.map { |_holder, command| command.hecks_name }.tally.select { |_name, count| count > 1 }
180
+ issues.map do |holder, command|
181
+ label = if repeated.key?(command.hecks_name)
182
+ "#{Naming.words(command.hecks_name)} (#{Naming.words(holder.hecks_name).downcase})"
183
+ end
184
+ index.link(:command, command.hecks_name, within: holder.hecks_name, label: label)
185
+ end
186
+ end
187
+
188
+ # ── small carpentry ─────────────────────────────────────────────
189
+
190
+ def split_trigger(dotted)
191
+ holder, _dot, command = dotted.to_s.rpartition(".")
192
+ [holder, command]
193
+ end
194
+
195
+ def bare(qualified) = qualified.to_s.split(".").last
196
+
197
+ def with_period(text) = text.to_s.strip.end_with?(".", "!", "?") ? text.to_s.strip : "#{text.to_s.strip}."
198
+
199
+ def lower_first(text) = text.sub(/\A[[:upper:]]/, &:downcase)
200
+
201
+ def upper_first(text) = text.sub(/\A[[:lower:]]/, &:upcase)
202
+ end
203
+ end
204
+ end
205
+ end