hecks 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
  3. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  4. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  5. data/lib/hecks/behaviors/expectations.rb +32 -4
  6. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  7. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  8. data/lib/hecks/bluebook/model_check.rb +148 -17
  9. data/lib/hecks/forms/field_shape.rb +5 -3
  10. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  11. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  12. data/lib/hecks/fuzzing/form_census.rb +184 -0
  13. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  14. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  15. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  16. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  17. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  18. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  19. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  20. data/lib/hecks/fuzzing/properties.rb +23 -2
  21. data/lib/hecks/fuzzing/replay.rb +130 -20
  22. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  23. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  24. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  25. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  26. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  27. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  28. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  29. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  30. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  31. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  32. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  33. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  34. data/lib/hecks/fuzzing.rb +6 -0
  35. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  36. data/lib/hecks/naming.rb +70 -2
  37. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  38. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  39. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
  40. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  41. data/lib/hecks/projections/glossary/html.rb +250 -0
  42. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  43. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  44. data/lib/hecks/projections/glossary/page.css +271 -0
  45. data/lib/hecks/projections/glossary/page.js +72 -0
  46. data/lib/hecks/projections/glossary/sections.rb +17 -0
  47. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  48. data/lib/hecks/projections/glossary.rb +214 -286
  49. data/lib/hecks/projector/narrate_projector.rb +4 -11
  50. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  51. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  52. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  53. data/lib/hecks/runtime/entity_element.rb +168 -16
  54. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  55. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  56. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  57. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  58. data/lib/hecks/runtime/registry.rb +12 -0
  59. data/lib/hecks/runtime/routing.rb +67 -2
  60. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  61. data/lib/hecks/runtime/value/coercion.rb +77 -115
  62. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  63. data/lib/hecks/runtime/value.rb +7 -2
  64. data/lib/hecks/version.rb +1 -1
  65. data/lib/hecks/vocabulary.rb +2 -1
  66. metadata +23 -2
@@ -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