Module: Hecks::Projector::CliProjector
- Defined in:
- lib/hecks/projector/cli_projector.rb
Overview
A BLUEBOOK, PROJECTED AS ITS OWN COMMAND-LINE SURFACE.
Every verb a domain declares is a subcommand; every argument is an option whose TYPE, whose admitted values and whose required-ness are already stated in the chapter. A hand-written CLI restates all of it and then drifts — and the first thing to drift is the help text, which is the only part anybody reads.
WHAT IS PROJECTED, AND WHAT IS NOT. This answers the SURFACE — the verb
tree, the argument spec, the usage text — and nothing executes here. One
small generic runner (bin/run) boots a domain, asks for this, parses
against it and dispatches.
The alternative was generating an executable per domain, which is what
bin/project_rust does for a whole runtime and would be the more
spectacular version of this. It was not taken: a generated program is a
second copy of the dispatch logic, and it needs regenerating on every
bluebook edit — a second tax beside the era gate, paid for a file nobody
reads. Projecting the surface keeps one dispatcher and a help text that
cannot be stale, because it is computed at the moment it is printed.
THE TYPING IS THE POINT. A CLI hands everything over as a String.
sequence.value=99 has to become the Integer 99 or the runtime refuses
it, and the only honest place to learn that is the value object's own
declared field type. A CLI that guessed — "it looks like a number" —
would send 99 for a version string of "99" and be wrong in a way nobody
could see.
Class Method Summary collapse
- .aliases(specs) ⇒ Object
-
.call(bluebook:, options: {}) ⇒ Object
TWO NAMESPACES, NOT ONE —
{ verbs:, questions:, usage: }. -
.claim(verbs, name, spec) ⇒ Object
A NAME IS CLAIMED ONCE.
- .closed_members(value_object, field) ⇒ Object
-
.command_spec(bluebook, aggregate, entity, command) ⇒ Object
── one verb ──────────────────────────────────────────────────────.
- .example_qualified(verbs) ⇒ Object
-
.first_sentence(text) ⇒ Object
A QUERY's
descriptionis written as a paragraph — it argues for why the list is worth reading. - .fqn(bluebook, aggregate, verb, entity = nil) ⇒ Object
- .name_for(aggregate, verb, entity = nil) ⇒ Object
-
.options_for(attribute, holder, aggregate, prefix = nil, optional = nil) ⇒ Object
A VALUE OBJECT BECOMES ONE OPTION PER FIELD, dotted.
-
.port_spec(bluebook, aggregate, port, operation) ⇒ Object
A PORT OPERATION READS AS A VERB BUT REPORTS AS A BOUNDARY.
- .port_summary(port, operation) ⇒ Object
- .query_spec(bluebook, aggregate, entity, query) ⇒ Object
- .reference_option(attribute) ⇒ Object
-
.refusals(command, holder) ⇒ Object
Every way this verb can say no, in the chapter's own words — printed by
--helpbefore the caller spends a dispatch finding out. -
.report_spec(bluebook, model) ⇒ Object
A ROOTLESS REPORT TAKES NOTHING; a rooted one takes the id of the record it is a view of, under the name the model gave that reference.
- .scalar_option(path, field, optional, enum: []) ⇒ Object
-
.shorten(specs) ⇒ Object
A last segment is claimed only if exactly one verb ends in it.
-
.usage(bluebook, verbs, questions, options) ⇒ Object
── the help ──────────────────────────────────────────────────────.
- .value_object_for(attribute, holder, aggregate) ⇒ Object
- .verb_help(program, name, spec, ask: false) ⇒ Object
Class Method Details
.aliases(specs) ⇒ Object
125 126 127 128 129 130 |
# File 'lib/hecks/projector/cli_projector.rb', line 125 def aliases(specs) specs.each_with_object({}) do |(name, spec), map| map[name] = name map[spec[:short]] = name end end |
.call(bluebook:, options: {}) ⇒ Object
TWO NAMESPACES, NOT ONE — { verbs:, questions:, usage: }.
A chapter may legally declare a command and a query of one name: the
language namespaces them and Banking::Account.Open is both, in the
corpus, today. A single flat list of subcommands has to pick one, and
picking silently is how Ticket.Filed sat undetected in this
repository for a day.
So a question is asked with ask: bin/run ask account.open. It is
this codebase own word — Query::AskOption, "the ask" — it is
shell-safe where a ? suffix would be eaten by globbing, and it makes
the collision impossible rather than detected. It also reads as what it
is: everything under ask changes nothing.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/hecks/projector/cli_projector.rb', line 48 def call(bluebook:, options: {}) verbs = {} questions = {} bluebook.aggregates.each do |aggregate| aggregate.commands.each { |c| claim(verbs, name_for(aggregate, c), command_spec(bluebook, aggregate, nil, c)) } aggregate.queries.each { |q| claim(questions, name_for(aggregate, q), query_spec(bluebook, aggregate, nil, q)) } aggregate.entities.each do |entity| entity.commands.each { |c| claim(verbs, name_for(aggregate, c, entity), command_spec(bluebook, aggregate, entity, c)) } entity.queries.each { |q| claim(questions, name_for(aggregate, q, entity), query_spec(bluebook, aggregate, entity, q)) } end # A PORT IS A VERB TOO, and leaving it off the map was a real gap # rather than a tasteful omission. The runtime has always dispatched # a port operation by exactly the same name as a command — the # projection simply never listed one, so `run_specs` and `file` # answered "no such verb" while working perfectly through Ruby. # # It matters most for the caller with no other door. An agent that # may not shell out reaches this domain ONLY through the projected # CLI, and a port it cannot see is a capability it does not have. aggregate.ports.each do |port| port.operations.each { |o| claim(verbs, name_for(aggregate, o), port_spec(bluebook, aggregate, port, o)) } end end # A REPORT IS A QUESTION TOO, and leaving it off was the same gap the # ports had: `Dispatcher#query` has always answered `Domain.ReportName`, # the projection simply never listed one — so the composed reads worked # from Ruby and did not exist for anybody whose only door is the command # line. # # It matters most for exactly what a report is FOR. Every other question # here answers with rows and leaves the arithmetic to the reader; a # `group_by` report is the one that counts. An agent that cannot reach # it can list bugs all day and never answer "how are we doing". # # ONE DOT, NOT TWO — a report belongs to the chapter rather than to any # aggregate (that is what rootless means), so it is addressed # `QualityControl.BugsByStatus` where a query is # `QualityControl::Bug.Queue`. `Dispatcher#query` splits on precisely # that difference. bluebook.read_models.each do |model| claim(questions, Naming.snake(model.hecks_name), report_spec(bluebook, model)) end # THE SHORT SPELLING, WHERE IT CANNOT BE AMBIGUOUS. `pizzas # create_pizza` rather than `pizzas order.create_pizza` — the # aggregate is worth typing only when two of them declare the same # verb, and in a one-aggregate domain it never is. Both spellings are # always accepted; `names` maps every accepted one to its canonical # key, and the display name is the shortest that is unambiguous. shorten(verbs) shorten(questions) { verbs: verbs, questions: questions, names: { command: aliases(verbs), question: aliases(questions) }, usage: usage(bluebook, verbs, questions, ) } end |
.claim(verbs, name, spec) ⇒ Object
A NAME IS CLAIMED ONCE. A command and a query of one name are legal in
a chapter — the language namespaces them — and ambiguous as
subcommands. Refused here rather than silently resolving to whichever
was walked first, which is how Ticket.Filed (a command) and
Ticket.Filed (a query) sat undetected until something flattened them.
137 138 139 140 141 142 143 144 145 |
# File 'lib/hecks/projector/cli_projector.rb', line 137 def claim(verbs, name, spec) if verbs.key?(name) raise Bluebook::DSL::Malformed, "two verbs project to the command-line name #{name.inspect}: " \ "#{verbs[name][:verb]} and #{spec[:verb]} — rename one" end verbs[name] = spec end |
.closed_members(value_object, field) ⇒ Object
329 330 331 332 333 |
# File 'lib/hecks/projector/cli_projector.rb', line 329 def closed_members(value_object, field) return [] unless value_object.closed_set? value_object.members.filter_map { |member| member[field.name] }.uniq end |
.command_spec(bluebook, aggregate, entity, command) ⇒ Object
── one verb ──────────────────────────────────────────────────────
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/hecks/projector/cli_projector.rb', line 161 def command_spec(bluebook, aggregate, entity, command) holder = entity || aggregate arguments = command.attributes.flat_map { |a| (a, holder, aggregate) } receiver = if entity :entity else (command.creates? ? nil : :aggregate) end legacy_arguments = [] # THE RECEIVER IS NOT A COMMAND ARGUMENT. An aggregate command names # its record through to; an entity command needs both the aggregate # record and the entity element within it. Keeping those paths in the # projected option list makes the human-facing request complete while # CommandRequest can remove them before it builds with: from the # command's declared facts. # # Existing aggregate scripts may still spell the receiver id=... . # That alias is deliberately hidden from help and recorded separately # as legacy_arguments; new help and examples teach only to=... . if entity arguments = [ { path: "to.aggregate", type: "String", required: true, note: "id of the #{aggregate.hecks_name} holding the #{entity.hecks_name}" }, { path: "to.entity", type: "String", required: true, note: "id of the #{entity.hecks_name} to act on" } ] + arguments elsif receiver == :aggregate arguments = [{ path: "to", type: "String", required: true, note: "id of the #{aggregate.hecks_name} to act on" }] + arguments legacy_arguments = [{ path: "id", type: "String", required: true }] end { verb: fqn(bluebook, aggregate, command, entity), kind: :command, summary: command.goal, role: command.role, creates: command.creates?, receiver: receiver, legacy_receiver: (receiver == :aggregate ? :id : nil), legacy_arguments: legacy_arguments, refusals: refusals(command, holder), arguments: arguments } end |
.example_qualified(verbs) ⇒ Object
390 391 392 393 |
# File 'lib/hecks/projector/cli_projector.rb', line 390 def example_qualified(verbs) name, spec = verbs.find { |key, value| key != value[:short] } || verbs.first name ? "#{spec[:short]} is also #{name}" : "" end |
.first_sentence(text) ⇒ Object
A QUERY's description is written as a paragraph — it argues for why
the list is worth reading. A verb table wants the first sentence of
that argument; --help still prints the whole thing.
398 399 400 |
# File 'lib/hecks/projector/cli_projector.rb', line 398 def first_sentence(text) text.to_s.split(/(?<=\.)\s/).first.to_s end |
.fqn(bluebook, aggregate, verb, entity = nil) ⇒ Object
154 155 156 157 |
# File 'lib/hecks/projector/cli_projector.rb', line 154 def fqn(bluebook, aggregate, verb, entity = nil) [bluebook.name, "::", aggregate.hecks_name, ".", entity ? "#{entity.hecks_name}." : "", verb.hecks_name].join end |
.name_for(aggregate, verb, entity = nil) ⇒ Object
147 148 149 150 151 152 |
# File 'lib/hecks/projector/cli_projector.rb', line 147 def name_for(aggregate, verb, entity = nil) parts = [Naming.snake(aggregate.hecks_name)] parts << Naming.snake(entity.hecks_name) if entity parts << Naming.snake(verb.hecks_name) parts.join(".") end |
.options_for(attribute, holder, aggregate, prefix = nil, optional = nil) ⇒ Object
A VALUE OBJECT BECOMES ONE OPTION PER FIELD, dotted. commit typed
CommitRef is --commit.value, because that is the shape the runtime
wants and a flat --commit would have to guess which field it meant.
Single-field value objects — almost all of them — read fine either way,
and the runner accepts the short form for exactly those.
RECURSIVE, AND IT HAS TO BE. A value object may hold another one —
pizzas' Pizza holds a Price and a Size — so stopping after one
level produced pizza.price_cents=1500 and sent the STRING "1500"
where { cents: 1500 } belonged.
The runtime took it. qa/FINDINGS.md #2 is exactly that gap —
Value::Coercion.build does not validate nested value objects — so a
one-level CLI is not merely inconvenient, it is a machine for writing
malformed records into a real store, which is what it did on its first
run against the pizzas database.
282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 |
# File 'lib/hecks/projector/cli_projector.rb', line 282 def (attribute, holder, aggregate, prefix = nil, optional = nil) path = [prefix, attribute.name].compact.join(".") optional = optional || attribute.optional? return [reference_option(attribute)] if attribute.reference? value_object = value_object_for(attribute, holder, aggregate) return [scalar_option(path, attribute, optional)] unless value_object # A LIST SAYS SO, ALL THE WAY DOWN TO ITS LEAVES. # # Without this a `list_of(Tag)` projected exactly like a single Tag: # one option, `tags.value`, indistinguishable from a scalar. So the # help said to pass one, `CliDoor#bury` overwrote the leaf each time, # and passing two tags stored the second and lost the first WITHOUT # SAYING ANYTHING. A missing argument is refused loudly; a forgotten # one is not, which makes it the more expensive of the two by far. # # The flag is carried on the leaf rather than kept beside the # attribute because the leaf is all `CliDoor` ever sees — it is handed # a path and a spec, and reuniting them with the attribute that # produced them would be a lookup that exists only to answer this. fields = value_object.attributes.flat_map do |field| nested = value_object_for(field, value_object, aggregate) next (field, value_object, aggregate, path, optional) if nested scalar_option("#{path}.#{field.name}", field, optional || field.optional?, enum: closed_members(value_object, field)) end return fields unless attribute.list? fields.map { |option| option.merge(list: true, note: [option[:note], "repeatable"].compact.join("; ")) } end |
.port_spec(bluebook, aggregate, port, operation) ⇒ Object
A PORT OPERATION READS AS A VERB BUT REPORTS AS A BOUNDARY.
creates: false because it makes no record, and refusals: []
because it has none in the sense every other verb means: a command's
refusals are sentences the chapter will say back to you, and an
outbound operation's failure is somebody else's sentence, unknowable
from here.
THE SUMMARY NAMES BOTH ENDINGS, which is the one thing a caller most
needs and cannot infer. run_specs looks like it either works or
errors; what it actually does is answer SpecsCompleted even when the
suite is red, and refuse only when rspec could not run. Somebody
reading --help should not have to open the hecksagon to find that
out.
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 |
# File 'lib/hecks/projector/cli_projector.rb', line 215 def port_spec(bluebook, aggregate, port, operation) arguments = (:aggregate, aggregate, nil) + operation.attributes.flat_map { |a| (a, aggregate, aggregate) } # THE WIRE NAME CARRIES THE PORT, THE TYPED NAME DOES NOT. # # `Dispatcher#dispatch` splits a verb into head and sub and looks the # head up as a port, so a port operation is addressed # `Aggregate.Port.Operation` — three parts where a command has two. # But nobody should have to type `sweep.toolchain.run_specs`: which # port a verb goes out through is wiring, and the caller's business is # what they want done. So the projection spells the verb in full and # names it short, which is the same split `shorten` already makes. { verb: [fqn(bluebook, aggregate, operation).sub(/\.[^.]+\z/, ""), port.name, operation.hecks_name].join("."), kind: :command, creates: false, receiver: :aggregate, refusals: [], role: operation.outbound? ? "#{aggregate.hecks_name} asking #{port.name}" : "#{port.name} telling #{aggregate.hecks_name}", summary: port_summary(port, operation), arguments: arguments } end |
.port_summary(port, operation) ⇒ Object
234 235 236 237 238 |
# File 'lib/hecks/projector/cli_projector.rb', line 234 def port_summary(port, operation) return "#{port.name} reports it; emits #{operation.emits.join(', ')}" unless operation.outbound? "Ask #{port.name} — answers #{operation.answers}, refuses #{operation.refuses}" end |
.query_spec(bluebook, aggregate, entity, query) ⇒ Object
255 256 257 258 259 260 261 262 263 |
# File 'lib/hecks/projector/cli_projector.rb', line 255 def query_spec(bluebook, aggregate, entity, query) arguments = Array(query.to_h[:attributes]).flat_map do |declared| attribute = query.attributes.find { |a| a.name.to_s == declared[:name].to_s } attribute ? (attribute, entity || aggregate, aggregate) : [] end { verb: fqn(bluebook, aggregate, query, entity), kind: :query, summary: query.description, arguments: arguments } end |
.reference_option(attribute) ⇒ Object
316 317 318 319 |
# File 'lib/hecks/projector/cli_projector.rb', line 316 def reference_option(attribute) { path: attribute.name.to_s, type: "String", required: !attribute.optional?, note: "id of a #{attribute.type.target_name}" } end |
.refusals(command, holder) ⇒ Object
Every way this verb can say no, in the chapter's own words — printed
by --help before the caller spends a dispatch finding out.
347 348 349 350 351 352 353 354 355 356 |
# File 'lib/hecks/projector/cli_projector.rb', line 347 def refusals(command, holder) out = [] lifecycle = holder.lifecycle froms = lifecycle && lifecycle.transitions.filter_map do |name, transition| Array(transition.from) if name.to_s == command.hecks_name end.flatten.uniq out << "#{lifecycle.field} is not #{froms.join(' or ')}" if froms && !froms.empty? out += command.attributes.select(&:reference?).map { |r| "no #{r.type.target_name} has that #{r.name}" } out + command.givens.map(&:description) end |
.report_spec(bluebook, model) ⇒ Object
A ROOTLESS REPORT TAKES NOTHING; a rooted one takes the id of the record it is a view of, under the name the model gave that reference.
242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'lib/hecks/projector/cli_projector.rb', line 242 def report_spec(bluebook, model) arguments = if model.reference_target [{ path: model.reference_name.to_s, type: "String", required: true, note: "id of the #{model.reference_target} this is a view of" }] else [] end { verb: "#{bluebook.name}.#{model.hecks_name}", kind: :query, summary: model.description, arguments: arguments } end |
.scalar_option(path, field, optional, enum: []) ⇒ Object
321 322 323 324 325 326 327 |
# File 'lib/hecks/projector/cli_projector.rb', line 321 def scalar_option(path, field, optional, enum: []) option = { path: path, type: field.type.to_s, required: !optional } option[:enum] = enum unless enum.empty? option[:pattern] = field.pattern if field.respond_to?(:pattern) && field.pattern option[:default] = field.default if field.respond_to?(:default) && !field.default.nil? option end |
.shorten(specs) ⇒ Object
A last segment is claimed only if exactly one verb ends in it. Two
aggregates declaring Close keep customer.close and account.close,
which is the honest answer — a CLI that picked one would be choosing
for the caller.
117 118 119 120 121 122 123 |
# File 'lib/hecks/projector/cli_projector.rb', line 117 def shorten(specs) tails = specs.keys.group_by { |name| name.split(".").last } specs.each do |name, spec| tail = name.split(".").last spec[:short] = tails[tail].length == 1 ? tail : name end end |
.usage(bluebook, verbs, questions, options) ⇒ Object
── the help ──────────────────────────────────────────────────────
360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 |
# File 'lib/hecks/projector/cli_projector.rb', line 360 def usage(bluebook, verbs, questions, ) program = [:program] || "bin/run" only = [:verb] # WHICH NAMESPACE, when both hold the name. `options[:ask]` says so; # without it a `--help` for a question would print the command that # shares its name, which banking has and which is how this was found. if only pool = [:ask] ? questions : verbs key = aliases(pool)[only] || ([:ask] ? nil : aliases(questions)[only]) spec = pool[key] || questions[key] return verb_help(program, spec[:short], spec, ask: [:ask]) if spec end width = (verbs.values + questions.values).map { |spec| spec[:short].length }.max.to_i out = ["#{bluebook.name} — #{bluebook.vision}", "", " #{program} <verb> [name=value …] do something", " #{program} ask <question> [name=value …] read something", ""] out << "verbs:" verbs.each_value { |spec| out << " #{spec[:short].ljust(width)} #{spec[:summary]}" } out << "" out << "questions (nothing here changes anything):" questions.each_value { |spec| out << " #{spec[:short].ljust(width)} #{first_sentence(spec[:summary])}" } out << "" out << " #{program} <verb> --help what one verb wants, and every way it refuses" out << " a verb can always be spelled in full — #{example_qualified(verbs)}" out.join("\n") end |
.value_object_for(attribute, holder, aggregate) ⇒ Object
335 336 337 338 339 340 341 342 343 |
# File 'lib/hecks/projector/cli_projector.rb', line 335 def value_object_for(attribute, holder, aggregate) [holder, aggregate].compact.each do |scope| next unless scope.respond_to?(:value_objects) found = scope.value_objects.find { |v| v.hecks_name == attribute.type.to_s } return found if found end nil end |
.verb_help(program, name, spec, ask: false) ⇒ Object
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 |
# File 'lib/hecks/projector/cli_projector.rb', line 402 def verb_help(program, name, spec, ask: false) out = ["#{name} — #{spec[:summary]}", ""] out << "dispatches #{spec[:verb]}" if spec[:kind] == :command out << "reads #{spec[:verb]}" if spec[:kind] == :query out << "issued by #{spec[:role]}" if spec[:role] out << "" invocation = ask ? "#{program} ask #{name}" : "#{program} #{name}" out << " #{invocation}#{spec[:arguments].map { |a| " #{a[:path]}=…" }.join}" out << "" unless spec[:arguments].empty? width = spec[:arguments].map { |a| a[:path].length }.max spec[:arguments].each do |argument| notes = [] notes << argument[:type] notes << "one of #{argument[:enum].join(', ')}" if argument[:enum] notes << "matches #{argument[:pattern]}" if argument[:pattern] notes << "defaults to #{argument[:default].inspect}" unless argument[:default].nil? notes << argument[:note] if argument[:note] notes << "optional" unless argument[:required] out << " #{argument[:path].ljust(width)} #{notes.join('; ')}" end out << "" end unless Array(spec[:refusals]).empty? out << "refused when:" spec[:refusals].each { |refusal| out << " #{refusal}" } out << "" end out.join("\n") end |