Module: Hecks::Facade::CliRunner
- Defined in:
- lib/hecks/facade/cli_runner.rb
Overview
THE RUNNER BEHIND A PROJECTED CLI.
Projector::CliProjector answers what a domain's command line LOOKS
like; this is the twenty lines that parse against it and dispatch. It
lives in lib/ rather than in a bin/ because more than one front door
wants it — bin/run for whichever domain you are standing in, bin/qc
pinned to the QA ledger — and a second copy of the parse-and-dispatch
would be the exact duplication the projection exists to avoid.
NO IO. It answers [text, status] and never prints or exits, so a spec
can call it without capturing streams or trapping SystemExit. The bin/
scripts do the printing, the same division Router and JsonDoor
already keep against HTTP.
Class Method Summary collapse
-
.answered(handle) ⇒ Object
A PORT OPERATION HAS NO STATE, AND ITS PAYLOAD IS THE ENTIRE POINT.
-
.call(runtime:, argv:, program: "bin/run") ⇒ Object
[text, status]— status 0 answered, 1 refused or misused. - .dispatch(runtime, spec, name, rest, program, asking) ⇒ Object
- .shared_prefix(one, other) ⇒ Object
-
.stamp_time(runtime, spec, args) ⇒ Object
THE CLOCK, FILLED IN AT THE DOOR.
-
.unknown(cli, name, asking, program) ⇒ Object
A NEAR MISS IS WORTH MORE THAN A LIST.
Class Method Details
.answered(handle) ⇒ Object
A PORT OPERATION HAS NO STATE, AND ITS PAYLOAD IS THE ENTIRE POINT.
A command answers with the record it changed, so naming the events is
enough — the interesting part is in state. A port operation changes
no record: it asked something outside and came back with what was
said, and that lives ONLY in the event payload. Reporting names alone
would print SpecsCompleted and drop the spec output on the floor.
This is what makes a projected CLI usable as somebody's only door. An
agent that may not shell out cannot run rspec and read the terminal;
it asks the port and reads the answer, and if the answer is a bare
event name then the door leads nowhere and it needs a shell after all.
BOTH ENDINGS COME BACK THE SAME WAY, and the status stays 0 for both.
A refusal here is not a misuse — IssueStillOpen and SuiteFailed
are answers the caller asked for, correctly delivered. Exit 1 is for
"you typed something wrong", and conflating the two would have a
scripted agent treat a healthy no as a broken call.
148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/hecks/facade/cli_runner.rb', line 148 def answered(handle) # THE ID COMES OFF THE EVENT, because a port operation hydrates no # instance and the handle's own `id` is nil by design. The event knows # which record was asked about — it was stamped with it — and printing # `null` beside a payload that plainly says `SW-TOOL` would read as a # bug in something. { id: handle.id || handle.events.first&.id, events: handle.events.map do |event| { name: event.name, payload: JsonDoor.materialize(event.payload) } end } end |
.call(runtime:, argv:, program: "bin/run") ⇒ Object
[text, status] — status 0 answered, 1 refused or misused.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
# File 'lib/hecks/facade/cli_runner.rb', line 27 def call(runtime:, argv:, program: "bin/run") bluebook = runtime.registry.bluebooks.values.first cli = Projector.call(:cli, bluebook: bluebook, options: { program: program }) name = argv.first return [cli[:usage], 0] if name.nil? || %w[--help -h help].include?(name) # `ask` PUTS A QUESTION IN ITS OWN NAMESPACE — a chapter may declare a # command and a query of one name, and banking does. asking = name == "ask" argv = argv[1..] if asking name = argv.first return [cli[:usage], 1] if name.nil? # RESOLVED THROUGH THE ALIAS MAP, so `pizzas create_pizza` and # `pizzas order.create_pizza` reach the same verb — the aggregate is # worth typing only when two of them declare the same word. pool = asking ? cli[:questions] : cli[:verbs] key = cli[:names][asking ? :question : :command][name] spec = pool[key] return [unknown(cli, name, asking, program), 1] unless spec rest = argv[1..] if rest.include?("--help") help = Projector.call(:cli, bluebook: bluebook, options: { program: program, verb: name, ask: asking })[:usage] return [help, 0] end dispatch(runtime, spec, name, rest, program, asking) end |
.dispatch(runtime, spec, name, rest, program, asking) ⇒ Object
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 |
# File 'lib/hecks/facade/cli_runner.rb', line 59 def dispatch(runtime, spec, name, rest, program, asking) args = stamp_time(runtime, spec, CliDoor.arguments(spec, rest)) if spec[:kind] == :query rows = runtime.query(spec[:verb], **args) return [JSON.pretty_generate(rows.map { |row| JsonDoor.materialize(row) }), 0] end # THE ANSWER IS SCOPED TO WHAT WAS ASKED. `bin/run`'s step-list form # reports the whole store because a corpus run is judged on all of it; # somebody who issued one verb wants that verb's outcome, and against a # Postgres-backed domain the full dump is every record there has been. request = CommandRequest.normalize(args, receiver: spec[:receiver], legacy_receiver: spec[:legacy_receiver]) handle = runtime.dispatch(spec[:verb], **request) return [JSON.pretty_generate(answered(handle)), 0] if handle.state.nil? [JSON.pretty_generate(id: handle.id, state: JsonDoor.materialize(handle.state), events: handle.events.map(&:name)), 0] rescue Runtime::NotFound, Runtime::TypeMismatch => e # A BAD ARGUMENT AND A MISSING RECORD BOTH LAND HERE, and both want the # same next step: read what the verb actually takes. ["#{e.}\n\n #{program} #{asking ? 'ask ' : ''}#{name} --help", 1] rescue *Runtime::DOMAIN_REFUSALS => e # THE REFUSAL IS THE PRODUCT — the chapter's own sentence, verbatim. [e., 1] end |
.shared_prefix(one, other) ⇒ Object
184 185 186 187 |
# File 'lib/hecks/facade/cli_runner.rb', line 184 def shared_prefix(one, other) length = [one.length, other.length].min (0...length).find { |index| one[index] != other[index] } || length end |
.stamp_time(runtime, spec, args) ⇒ Object
THE CLOCK, FILLED IN AT THE DOOR.
A staleness rule needs the time, and the sublanguage cannot ask for it
— a given that read the clock would judge the same record differently
on two runs, and every replay, audit and fuzz oracle here assumes it
does not. So now stays an ARGUMENT the predicate merely reads, and
the question becomes who types it. Before this, the caller did:
qa/quality_control target.claim id=QC held_by.value=me \
now.value=$(date +%s) window.value=900
which is a shell incantation in front of every claim, and one an agent gets wrong by pasting a stale number.
AT THE DOOR, NOT IN THE RUNTIME, and the distinction is load-bearing.
Ports::IdentityGeneration reasons the same question through for a
minted uuid and lands on "the value is baked into the caller's args at
the first live dispatch". A clock consulted INSIDE the interpreter
would not have that property — a recorded corpus step replayed
tomorrow would quietly get tomorrow's time, and the fuzzer's oracle and
the adapter-agreement gate both compare runs of exactly that shape. Here
it fills only what a person or an agent is typing, and runtime.dispatch
is left alone.
AN EXPLICIT VALUE ALWAYS WINS, so a spec or a caller reproducing a moment says so and is believed. This only supplies what was omitted.
BY NAME, WHICH IS THE ONE UNCOMFORTABLE PART. now is a plausible
domain word and nothing declares that it means the clock. It is
tolerable because this is a convenience layer rather than semantics —
the verb's own help says the argument exists, dispatch is unchanged,
and passing it explicitly is always available. The honest version is a
declaration in the chapter (attribute :now, Instant, from: :clock),
which is a language change: DSL, IR, the self-hosted grammar and its
goldens. Worth doing; not worth smuggling in here.
123 124 125 126 127 128 |
# File 'lib/hecks/facade/cli_runner.rb', line 123 def stamp_time(runtime, spec, args) return args unless spec[:arguments].any? { |argument| argument[:path].start_with?("now.") } return args if args.key?(:now) args.merge(now: { value: Ports::Clock.now(runtime.registry) }) end |
.unknown(cli, name, asking, program) ⇒ Object
A NEAR MISS IS WORTH MORE THAN A LIST. Somebody who typed
bug.discovr wants one line, not eighty-seven of them.
RANKED BY SHARED PREFIX, not by substring. Substring was the first
attempt and it finds nothing for the commonest typo of all — a dropped
letter, order.create_piza, which is a substring of nothing. Prefix
length survives an error anywhere after it, which is where errors are.
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/hecks/facade/cli_runner.rb', line 167 def unknown(cli, name, asking, program) # BOTH SPELLINGS ARE CANDIDATES. A caller who typed the qualified # form with a typo — `order.create_piza` — shares no prefix with the # short name `create_pizza`, so pooling only one of them suggests # nothing for half the mistakes anybody makes. pool = cli[:names][asking ? :question : :command].keys near = pool.map { |candidate| [shared_prefix(candidate, name), candidate] } .select { |shared, _| shared >= [name.length / 2, 3].max } .sort_by { |shared, candidate| [-shared, candidate] } .map(&:last) lines = ["no such #{asking ? 'question' : 'verb'}: #{name}"] lines += ["", "did you mean:", *near.first(5).map { |candidate| " #{candidate}" }] unless near.empty? lines += ["", " #{program}#{asking ? ' ask' : ''} for the full list"] lines.join("\n") end |