Module: Hecks::Projector::NarrateProjector
- Defined in:
- lib/hecks/projector/narrate_projector.rb
Overview
A BLUEBOOK, PROJECTED AS PROSE AN SME CAN READ BACK AND CONFIRM.
WHAT THIS IS FOR. DocsProjector already answers "what can I call and
what does it want" for the person implementing against a domain —
tables of arguments, shapes, refusal reasons. That is the wrong
register for the person who can actually say whether the domain is
RIGHT: the subject-matter expert who knows what an account is and has
never read a markdown table in their life. This projects the same IR
as sentences instead — "Debit — take money out. Issued by a Teller. It
only goes through if the balance covers it." — so a domain can be
read back to the person who can validate it without them learning the
DSL first.
SAME SOURCE, SAME GUARANTEE DocsProjector gives: nothing here is
invented. Every sentence quotes a description, goal, or given
already declared in the chapter; where a chapter says nothing, this
says nothing rather than manufacturing a sentence out of an
identifier. Registered as :narrate beside :docs, same call shape
(Projector.call(:narrate, bluebook: ...)), same aggregate-scoping
via options[:aggregate].
WHAT IT DOES NOT DO: replace DocsProjector. A shape table still says
"id of a Customer" more precisely than any sentence would, and an
implementer still wants that. This is the other document the same IR
is owed — one written for the reader who is being asked "is this
right?", not "how do I call it?"
Class Method Summary collapse
- .a_or_an(word) ⇒ Object
-
.aggregate_narrative(aggregate, depth) ⇒ Object
── one aggregate ─────────────────────────────────────────────────.
-
.call(bluebook:, options: {}) ⇒ Object
options[:heading]sets the top heading level, exactly asDocsProjectordoes — so this, too, can be spliced into a larger document rather than always starting at H1. -
.chapter_intro(bluebook, depth) ⇒ Object
── the chapter ───────────────────────────────────────────────────.
- .command_paragraph(command, holder) ⇒ Object
-
.conditions_of(command, holder) ⇒ Object
EVERY REQUIRED CONDITION, STATED AS SOMETHING THAT MUST BE TRUE — the same three sources
DocsProjector#refusals_ofreads (the lifecycle edge, a reference's existence, and the command's owngivens), but kept positive rather than phrased as a refusal reason. - .entity_narrative(aggregate, entity, depth) ⇒ Object
- .identity_sentence(holder) ⇒ Object
-
.lifecycle_narrative(holder) ⇒ Object
── the machine ───────────────────────────────────────────────────.
- .op_words(op) ⇒ Object
-
.queries_narrative(queries, holder_name, depth) ⇒ Object
── the reads ─────────────────────────────────────────────────────.
-
.reactions_narrative(bluebook, depth) ⇒ Object
── what happens on its own ───────────────────────────────────────.
-
.to_sentence_list(items, conj: "and") ⇒ Object
── small sentence carpentry ──────────────────────────────────────.
-
.verbs_narrative(holder, depth) ⇒ Object
── the verbs ─────────────────────────────────────────────────────.
Class Method Details
.a_or_an(word) ⇒ Object
238 239 240 |
# File 'lib/hecks/projector/narrate_projector.rb', line 238 def a_or_an(word) %w[a e i o u].include?(word.to_s[0].to_s.downcase) ? "an" : "a" end |
.aggregate_narrative(aggregate, depth) ⇒ Object
── one aggregate ─────────────────────────────────────────────────
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/hecks/projector/narrate_projector.rb', line 69 def aggregate_narrative(aggregate, depth) parts = [DocsProjector.h(depth, aggregate.hecks_name)] parts << aggregate.description if aggregate.description parts << identity_sentence(aggregate) refs = aggregate.attributes.select(&:reference?) parts << "Each one is linked to #{to_sentence_list(refs.map { |r| "#{a_or_an(r.type.target_name)} #{r.type.target_name}" })}." unless refs.empty? parts << lifecycle_narrative(aggregate) parts << verbs_narrative(aggregate, depth + 1) parts << queries_narrative(aggregate.queries, aggregate.hecks_name, depth + 1) aggregate.entities.each { |entity| parts << entity_narrative(aggregate, entity, depth + 1) } parts.compact.join("\n\n") end |
.call(bluebook:, options: {}) ⇒ Object
options[:heading] sets the top heading level, exactly as
DocsProjector does — so this, too, can be spliced into a larger
document rather than always starting at H1.
38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'lib/hecks/projector/narrate_projector.rb', line 38 def call(bluebook:, options: {}) depth = ([:heading] || 1).to_i only = [:aggregate] sections = [] sections << chapter_intro(bluebook, depth) unless only Array(DocsProjector.aggregates(bluebook, only)).each do |aggregate| sections << aggregate_narrative(aggregate, only ? depth : depth + 1) end sections << reactions_narrative(bluebook, depth + 1) unless only "#{sections.compact.join("\n\n").rstrip}\n" end |
.chapter_intro(bluebook, depth) ⇒ Object
── the chapter ───────────────────────────────────────────────────
53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/hecks/projector/narrate_projector.rb', line 53 def chapter_intro(bluebook, depth) parts = [DocsProjector.h(depth, bluebook.name)] parts << bluebook.vision if bluebook.vision = [] << "This is a #{bluebook.classification} domain." if bluebook.classification << "It was formerly known as `#{bluebook.formerly_known_as}`." if bluebook.formerly_known_as parts << .join(" ") unless .empty? names = bluebook.aggregates.map(&:hecks_name) parts << "It's told through #{to_sentence_list(names)}." unless names.empty? parts.join("\n\n") end |
.command_paragraph(command, holder) ⇒ Object
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/hecks/projector/narrate_projector.rb', line 127 def command_paragraph(command, holder) # THE GOAL, VERBATIM — same rule `DocsProjector` holds to: quoted # exactly as declared, not recased to fit mid-sentence, because the # promise this whole projector makes is that a sentence here is a # sentence the chapter actually wrote. sentences = ["**#{command.hecks_name}**#{command.goal ? " — #{command.goal}." : '.'}"] sentences << "Issued by #{a_or_an(command.role)} #{command.role}." if command.role # `acts_on.nil?`, NOT `creates?` — `creates?` answers true for every # verb an ENTITY declares (it never references itself; see # `Command#acts_on`'s own comment), so reading it directly here would # tell an SME that `LedgerEntry.Amend` brings a new ledger entry into # being, which is exactly backwards. sentences << "This is how a new #{holder.hecks_name} comes into being." if command.acts_on.nil? arguments = command.attributes.reject(&:reference?) sentences << "It takes #{to_sentence_list(arguments.map { |a| Forms::Humanize.label(a.name.to_s).downcase })}." unless arguments.empty? refs = command.attributes.select(&:reference?) sentences << "It's aimed at one existing #{to_sentence_list(refs.map { |r| r.type.target_name })}, by id." unless refs.empty? conditions = conditions_of(command, holder) sentences << "It only goes through if #{conditions.join('; ')}." unless conditions.empty? guarantees = command.ensures.map(&:description) sentences << "When it succeeds: #{guarantees.join('; ')}." unless guarantees.empty? sentences << "It records `#{command.emits.join('`, `')}` as a fact." unless command.emits.empty? sentences.join(" ") end |
.conditions_of(command, holder) ⇒ Object
EVERY REQUIRED CONDITION, STATED AS SOMETHING THAT MUST BE TRUE —
the same three sources DocsProjector#refusals_of reads (the
lifecycle edge, a reference's existence, and the command's own
givens), but kept positive rather than phrased as a refusal
reason. "Refused unless not X" is a sentence a reader has to
invert in their head; "only goes through if X" is not.
164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/hecks/projector/narrate_projector.rb', line 164 def conditions_of(command, holder) conditions = [] lifecycle = holder.lifecycle if lifecycle froms = lifecycle.transitions.filter_map { |name, transition| Array(transition.from) if name.to_s == command.hecks_name }.flatten.uniq conditions << "its `#{lifecycle.field}` is currently #{to_sentence_list(froms.map { |f| "`#{f}`" }, conj: 'or')}" unless froms.empty? end conditions + command.givens.map(&:description) end |
.entity_narrative(aggregate, entity, depth) ⇒ Object
91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/hecks/projector/narrate_projector.rb', line 91 def entity_narrative(aggregate, entity, depth) parts = [DocsProjector.h(depth, "#{entity.hecks_name} (within #{aggregate.hecks_name})")] parts << entity.description if entity.description parts << "Reached through its #{aggregate.hecks_name} — you address it by the #{aggregate.hecks_name}'s " \ "id together with its own `#{entity.identity_heads.join('`, `')}`." parts << lifecycle_narrative(entity) parts << verbs_narrative(entity, depth + 1) parts << queries_narrative(entity.queries, entity.hecks_name, depth + 1) parts.compact.join("\n\n") end |
.identity_sentence(holder) ⇒ Object
85 86 87 88 89 |
# File 'lib/hecks/projector/narrate_projector.rb', line 85 def identity_sentence(holder) return nil if holder.identity_heads.empty? "Every #{holder.hecks_name} is identified by its #{to_sentence_list(holder.identity_heads.map { |h| "`#{h}`" })}." end |
.lifecycle_narrative(holder) ⇒ Object
── the machine ───────────────────────────────────────────────────
105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/hecks/projector/narrate_projector.rb', line 105 def lifecycle_narrative(holder) lifecycle = holder.lifecycle or return nil sentences = ["It carries a `#{lifecycle.field}`, starting out at `#{lifecycle.default}`."] lifecycle.transitions.each do |name, transition| froms = Array(transition.from).map { |f| "`#{f}`" } sentences << "**#{name}** moves it from #{to_sentence_list(froms, conj: 'or')} to `#{transition.target}`." end sentences << "A verb not listed here can be issued from any state." sentences.join(" ") end |
.op_words(op) ⇒ Object
203 204 205 |
# File 'lib/hecks/projector/narrate_projector.rb', line 203 def op_words(op) { eq: "is", lt: "under", lte: "at most", gt: "over", gte: "at least" }[op.to_s.to_sym] || op.to_s end |
.queries_narrative(queries, holder_name, depth) ⇒ Object
── the reads ─────────────────────────────────────────────────────
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/hecks/projector/narrate_projector.rb', line 180 def queries_narrative(queries, holder_name, depth) return nil if queries.empty? header = DocsProjector.h(depth, "Questions you can ask about #{a_or_an(holder_name)} #{holder_name}") lines = queries.map do |query| shape = query.to_h takes = Array(shape[:attributes]).map { |a| Forms::Humanize.label(a[:name].to_s).downcase } # `w[:value]` ALREADY WEARS ITS OWN QUOTES OR COLON — it is a # `Literal.render`ed string (see lib/hecks/literal.rb), not a raw # Ruby value, so wrapping it in `.inspect` here would quote an # already-quoted string a second time. filters = Array(shape[:wheres]).map { |w| "`#{w[:field]}` #{op_words(w[:op])} #{w[:value]}" } sentence = "- **#{query.hecks_name}**" sentence << " (given #{to_sentence_list(takes)})" unless takes.empty? sentence << " — #{query.description}" if query.description sentence << "." unless sentence.end_with?(".") sentence << " Only where #{to_sentence_list(filters)}." unless filters.empty? sentence end "#{header}\n\n#{lines.join("\n")}" end |
.reactions_narrative(bluebook, depth) ⇒ Object
── what happens on its own ───────────────────────────────────────
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/hecks/projector/narrate_projector.rb', line 209 def reactions_narrative(bluebook, depth) return nil if bluebook.policies.empty? && bluebook.process_managers.empty? parts = [DocsProjector.h(depth, "Reactions")] bluebook.policies.each do |policy| elsewhere = policy.target_domain ? " in #{policy.target_domain}" : "" parts << "Whenever `#{policy.on_event}` happens, `#{policy.trigger_command}` fires on its own#{elsewhere} — nobody has to ask for it." end bluebook.process_managers.each do |saga| shape = saga.to_h parts << "**#{shape[:name]}** is a saga: it starts when `#{shape[:starts_on]}` happens and ends when " \ "`#{shape[:ends_on]}` happens, with each run tracked by its `#{shape[:correlates_by]}`. Along the " \ "way it moves through #{Array(shape[:states]).map { |s| "`#{s}`" }.join(' → ')}." end parts.join("\n\n") end |
.to_sentence_list(items, conj: "and") ⇒ Object
── small sentence carpentry ──────────────────────────────────────
229 230 231 232 233 234 235 236 |
# File 'lib/hecks/projector/narrate_projector.rb', line 229 def to_sentence_list(items, conj: "and") case items.size when 0 then "" when 1 then items[0].to_s when 2 then "#{items[0]} #{conj} #{items[1]}" else "#{items[0..-2].join(', ')}, #{conj} #{items[-1]}" end end |
.verbs_narrative(holder, depth) ⇒ Object
── the verbs ─────────────────────────────────────────────────────
119 120 121 122 123 124 125 |
# File 'lib/hecks/projector/narrate_projector.rb', line 119 def verbs_narrative(holder, depth) return nil if holder.commands.empty? header = DocsProjector.h(depth, "What can happen to #{a_or_an(holder.hecks_name)} #{holder.hecks_name}") body = holder.commands.map { |command| command_paragraph(command, holder) }.join("\n\n") "#{header}\n\n#{body}" end |