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
|
@@ -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
|