inquirex 0.8.0 → 0.9.5

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.
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module DSL
5
+ # Collects the fields of a `send_email` block. Every setter is an explicit
6
+ # builder method — the block form mirrors the step builders (`type`,
7
+ # `question`, ...) rather than a keyword-argument hash:
8
+ #
9
+ # send_email if: not_empty(:email) do
10
+ # to "{{email}}"
11
+ # from "forms@agentica.group"
12
+ # subject "Thanks {{name}}"
13
+ # markdown_text <<~TEXT
14
+ # Hi {{name}}, we got your answers:
15
+ #
16
+ # {{answers_summary}}
17
+ # TEXT
18
+ # end
19
+ class SendEmailBuilder
20
+ # @return [Hash{Symbol => Object}] collected SendEmail constructor params
21
+ attr_reader :params
22
+
23
+ def initialize
24
+ @params = {}
25
+ end
26
+
27
+ # @param value [String] recipient template ({{field}} placeholders allowed)
28
+ # @return [void]
29
+ def to(value)
30
+ @params[:to] = value
31
+ end
32
+
33
+ # @param value [String] sender template
34
+ # @return [void]
35
+ def from(value)
36
+ @params[:from] = value
37
+ end
38
+
39
+ # @param value [String] carbon-copy template
40
+ # @return [void]
41
+ def cc(value)
42
+ @params[:cc] = value
43
+ end
44
+
45
+ # @param value [String] blind-carbon-copy template
46
+ # @return [void]
47
+ def bcc(value)
48
+ @params[:bcc] = value
49
+ end
50
+
51
+ # @param value [String] reply-to template
52
+ # @return [void]
53
+ def reply_to(value)
54
+ @params[:reply_to] = value
55
+ end
56
+
57
+ # @param value [String] subject template
58
+ # @return [void]
59
+ def subject(value)
60
+ @params[:subject] = value
61
+ end
62
+
63
+ # @param value [Hash] extra headers (values support {{field}})
64
+ # @return [void]
65
+ def headers(value)
66
+ @params[:headers] = value
67
+ end
68
+
69
+ # @param value [String, Hash] plain-text body template or { file: "path" }
70
+ # @return [void]
71
+ def text(value)
72
+ @params[:text] = value
73
+ end
74
+
75
+ # @param value [String, Hash] Markdown body template or { file: "path" }
76
+ # @return [void]
77
+ def markdown_text(value)
78
+ @params[:markdown_text] = value
79
+ end
80
+
81
+ # @param value [String, Hash] HTML body template or { file: "path" }
82
+ # @return [void]
83
+ def html(value)
84
+ @params[:html] = value
85
+ end
86
+ end
87
+ end
88
+ end
@@ -18,6 +18,7 @@ module Inquirex
18
18
  @skip_if = nil
19
19
  @default = nil
20
20
  @required = true
21
+ @requiredness = nil
21
22
  @compute = nil
22
23
  @widget_hints = {}
23
24
  @accumulations = []
@@ -138,8 +139,34 @@ module Inquirex
138
139
  # end
139
140
  #
140
141
  # @param value [Boolean] false to make the step skippable
142
+ # @raise [Errors::DefinitionError] if the step also declares `optional` to the contrary
141
143
  def required(value = true)
142
- @required = value
144
+ assign_requiredness(:required, value ? true : false)
145
+ end
146
+
147
+ # Declares this step skippable — the inverse of {#required}, and the form
148
+ # most flows want. Since steps are required by default, `required true` is
149
+ # a no-op and the keyword only ever appears as `required false`; `optional`
150
+ # says the same thing without the double negative.
151
+ #
152
+ # Sugar only: both spellings serialize to the single wire field
153
+ # `"required": false`, so a definition round-trips identically however it
154
+ # was authored and no consumer has to learn a second key.
155
+ #
156
+ # @example Equivalent to `required false`, and easier to read
157
+ # ask :dependents do
158
+ # type :integer
159
+ # question "How many dependents?"
160
+ # optional
161
+ # default 0
162
+ # transition to: :done
163
+ # end
164
+ #
165
+ # @param value [Boolean] true (the default) to make the step skippable
166
+ # @return [void]
167
+ # @raise [Errors::DefinitionError] if the step also declares `required` to the contrary
168
+ def optional(value = true)
169
+ assign_requiredness(:optional, value ? false : true)
143
170
  end
144
171
 
145
172
  # Registers a compute block: auto-calculates a value from answers, not shown to user.
@@ -150,6 +177,51 @@ module Inquirex
150
177
  @compute = block
151
178
  end
152
179
 
180
+ # Declares the inclusive lower bound of a numeric step.
181
+ #
182
+ # Only meaningful for `:integer`, `:decimal` and `:currency`; declaring it
183
+ # on any other type raises at build time rather than being ignored.
184
+ #
185
+ # @example A headcount that cannot be negative
186
+ # ask :employees do
187
+ # type :integer
188
+ # question "How many employees?"
189
+ # min 0
190
+ # max 500
191
+ # transition to: :done
192
+ # end
193
+ #
194
+ # @param value [Numeric] inclusive lower bound
195
+ # @return [void]
196
+ def min(value)
197
+ @min = value
198
+ end
199
+
200
+ # Declares the inclusive upper bound of a numeric step.
201
+ #
202
+ # @param value [Numeric] inclusive upper bound
203
+ # @return [void]
204
+ # @see #min
205
+ def max(value)
206
+ @max = value
207
+ end
208
+
209
+ # Declares the increment a numeric step's stepper arrows move by.
210
+ #
211
+ # Defaults, when unset, to 1 for `:integer` and 0.01 for `:decimal` and
212
+ # `:currency` — the renderer applies that, not this builder, so an unset
213
+ # value stays absent from the wire format.
214
+ #
215
+ # Spelled `step_size` rather than `step` because a step *is* the unit of
216
+ # a flow; `steps.employees.step` would read as a nested flow step.
217
+ #
218
+ # @param value [Numeric] stepper increment
219
+ # @return [void]
220
+ # @see #min
221
+ def step_size(value)
222
+ @step_size = value
223
+ end
224
+
153
225
  # Builds the Node for the given step id.
154
226
  #
155
227
  # @param id [Symbol]
@@ -166,6 +238,9 @@ module Inquirex
166
238
  skip_if: @skip_if,
167
239
  default: @default,
168
240
  required: @required,
241
+ min: @min,
242
+ max: @max,
243
+ step_size: @step_size,
169
244
  widget_hints: resolve_widget_hints,
170
245
  accumulations: @accumulations
171
246
  )
@@ -173,6 +248,26 @@ module Inquirex
173
248
 
174
249
  private
175
250
 
251
+ # Records requiredness from either spelling, rejecting a step that says
252
+ # both things at once. `required false` alongside `optional` agrees and is
253
+ # allowed — harmlessly redundant — but `required` with `optional` is a
254
+ # contradiction, and silently letting the last call win would hide it.
255
+ #
256
+ # @param keyword [Symbol] :required or :optional, for the error message
257
+ # @param value [Boolean] the resulting requiredness
258
+ # @return [void]
259
+ # @raise [Errors::DefinitionError] on contradictory declarations
260
+ def assign_requiredness(keyword, value)
261
+ if !@requiredness.nil? && @requiredness != value
262
+ raise Errors::DefinitionError,
263
+ "Step declares both required and optional with conflicting values " \
264
+ "(#{keyword} would make it #{value ? "required" : "optional"}); use one"
265
+ end
266
+
267
+ @requiredness = value
268
+ @required = value
269
+ end
270
+
176
271
  def pick_accumulator_shape(lookup:, per_selection:, per_unit:, flat:)
177
272
  provided = { lookup:, per_selection:, per_unit:, flat: }.compact
178
273
  if provided.size != 1
@@ -74,6 +74,23 @@ module Inquirex
74
74
  @totals[name.to_sym] || 0
75
75
  end
76
76
 
77
+ # The running narrative of a `:text` accumulator — everything the user was
78
+ # shown and everything they answered, in the order it happened. This is
79
+ # what an LLM `summarize` step reads.
80
+ #
81
+ # @param name [Symbol] text accumulator name (e.g. :transcript)
82
+ # @return [String] empty when nothing has been captured yet
83
+ def text(name)
84
+ @totals[name.to_sym].to_s
85
+ end
86
+
87
+ # Every text accumulator's running narrative, keyed by name.
88
+ #
89
+ # @return [Hash{Symbol => String}]
90
+ def texts
91
+ text_accumulator_names.to_h { |name| [name, text(name)] }
92
+ end
93
+
77
94
  # @return [Node, nil] current step node, or nil if flow is finished
78
95
  def current_step
79
96
  return nil if finished?
@@ -101,9 +118,11 @@ module Inquirex
101
118
  result = @validator.validate(current_step, value)
102
119
  raise Errors::ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?
103
120
 
121
+ node = current_step
104
122
  @answers[@current_step_id] = value
105
123
  @suggestions.delete(@current_step_id)
106
- apply_accumulations(current_step, value)
124
+ apply_accumulations(node, value)
125
+ capture_transcript(Transcript.answer_entry(node, value))
107
126
  advance_step
108
127
  end
109
128
 
@@ -113,6 +132,8 @@ module Inquirex
113
132
  def advance
114
133
  raise Errors::AlreadyFinishedError, "Flow is already finished" if finished?
115
134
 
135
+ node = current_step
136
+ capture_transcript(Transcript.display_entry(node)) if node.display?
116
137
  advance_step
117
138
  end
118
139
 
@@ -152,6 +173,7 @@ module Inquirex
152
173
  end
153
174
  @skipped << @current_step_id unless @skipped.include?(@current_step_id)
154
175
  @suggestions.delete(@current_step_id)
176
+ capture_transcript(Transcript.skipped_entry(node))
155
177
  advance_step
156
178
  end
157
179
 
@@ -165,14 +187,21 @@ module Inquirex
165
187
 
166
188
  # Merges a hash of { step_id => value } into the top-level answers without
167
189
  # clobbering answers the user has already provided. Used by LLM clarify
168
- # steps to populate downstream answers from free-text extraction so that
169
- # `skip_if not_empty(:id)` rules on later steps will fire.
190
+ # steps to populate downstream answers from free-text extraction; a
191
+ # prefilled question is treated as answered and is never asked again.
170
192
  #
171
193
  # Nil/empty values in the hash are ignored so that "unknown" LLM outputs
172
194
  # don't spuriously satisfy `not_empty` rules.
173
195
  #
174
- # If the engine's current step becomes skippable as a result of the prefill,
175
- # it auto-advances past it.
196
+ # Values for steps with options (enum / multi_enum) are canonicalized via
197
+ # Node#resolve_option — matching is against the option's form VALUE, with
198
+ # a case-insensitive fallback and a label fallback ("US citizen or
199
+ # permanent resident" resolves to "us_person"). A value that matches
200
+ # neither value nor label is dropped, so junk never enters the answers.
201
+ #
202
+ # Prefilled answers contribute to accumulators exactly like typed ones,
203
+ # and if the engine's current step becomes skippable as a result of the
204
+ # prefill, it auto-advances past it.
176
205
  #
177
206
  # @param hash [Hash] answers keyed by step id
178
207
  # @return [Hash] the updated answers
@@ -185,13 +214,9 @@ module Inquirex
185
214
 
186
215
  sym = key.to_sym
187
216
  if multi_select_step?(sym)
188
- # Multi-select extraction is a hint, not a fact: the user may have
189
- # more selections in mind than the text revealed. Record it as a
190
- # suggestion so renderers pre-check the choices while the question
191
- # is still asked; skip_if rules see no answer and do not fire.
192
- @suggestions[sym] = Array(value) unless @answers.key?(sym)
217
+ prefill_suggestion(sym, value)
193
218
  else
194
- @answers[sym] = value unless @answers.key?(sym)
219
+ prefill_answer(sym, value)
195
220
  end
196
221
  end
197
222
  skip_if_needed unless finished?
@@ -319,6 +344,30 @@ module Inquirex
319
344
  end
320
345
  end
321
346
 
347
+ # @return [Array<Symbol>] names of the flow's :text accumulators
348
+ def text_accumulator_names
349
+ @definition.accumulators.filter_map { |name, acc| name if acc.text? }
350
+ end
351
+
352
+ # Appends one narrative entry to every text accumulator the flow declares.
353
+ #
354
+ # Called only from #answer, #skip, and #advance — the three points at
355
+ # which the user has actually seen or done something. Steps the engine
356
+ # elides on its own (skip_if, or a question already answered by an
357
+ # extraction) pass through #advance_step instead and are correctly absent
358
+ # from the narrative.
359
+ #
360
+ # @param entry [String, nil] formatted entry, or nil for nothing to record
361
+ # @return [void]
362
+ def capture_transcript(entry)
363
+ return if entry.nil? || entry.empty?
364
+
365
+ text_accumulator_names.each do |name|
366
+ existing = @totals[name].to_s
367
+ @totals[name] = existing.empty? ? entry : "#{existing}\n\n#{entry}"
368
+ end
369
+ end
370
+
322
371
  # The step's default as a concrete value: a Proc default (server-side only,
323
372
  # stripped from JSON) is called with the answers collected so far, exactly
324
373
  # as a renderer pre-filling the field would resolve it.
@@ -367,12 +416,44 @@ module Inquirex
367
416
  @completion_metadata = CompletionMetadata.new(engine: "inquirex", engine_version: VERSION)
368
417
  end
369
418
 
370
- # Auto-skips the current step if its skip_if rule is satisfied.
419
+ # Multi-select extraction is a hint, not a fact: the user may have more
420
+ # selections in mind than the text revealed. Record it as a suggestion so
421
+ # renderers pre-check the choices while the question is still asked;
422
+ # skip_if rules see no answer and do not fire. Each entry is
423
+ # canonicalized against the step's option values; unmatchable entries
424
+ # are dropped, and an all-junk extraction records no suggestion.
425
+ def prefill_suggestion(sym, value)
426
+ return if @answers.key?(sym)
427
+
428
+ node = @definition.step(sym)
429
+ resolved = Array(value).filter_map { |entry| node.resolve_option(entry) }
430
+ @suggestions[sym] = resolved unless resolved.empty?
431
+ end
432
+
433
+ # Single-value extraction is deterministic: the canonicalized value is
434
+ # recorded as the answer (feeding accumulators like a typed answer), and
435
+ # the question will be auto-skipped when reached. Unknown keys — schema
436
+ # fields with no matching step, e.g. a confidence score — are stored
437
+ # verbatim so rules can still read them.
438
+ def prefill_answer(sym, value)
439
+ return if @answers.key?(sym)
440
+
441
+ node = @definition.step_ids.include?(sym) ? @definition.step(sym) : nil
442
+ resolved = node ? node.resolve_option(value) : value
443
+ return if resolved.nil?
444
+
445
+ @answers[sym] = resolved
446
+ apply_accumulations(node, resolved) if node
447
+ end
448
+
449
+ # Auto-skips the current step when its skip_if rule is satisfied, or when
450
+ # it is a collecting step whose answer already exists (prefilled by an
451
+ # LLM extraction) — an answered question is never asked again.
371
452
  def skip_if_needed
372
453
  return if finished?
373
454
 
374
455
  node = @definition.step(@current_step_id)
375
- return unless node.skip?(@answers)
456
+ return unless node.skip?(@answers) || (node.collecting? && @answers.key?(@current_step_id))
376
457
 
377
458
  advance_step
378
459
  end
@@ -32,8 +32,25 @@ module Inquirex
32
32
  # Raised when serializing or deserializing a Definition to/from JSON fails.
33
33
  class SerializationError < Error; end
34
34
 
35
- # Raised when a post-completion action cannot execute structurally,
36
- # e.g. send_email is used without the mail gem installed.
37
- class ActionError < Error; end
35
+ # Raised when DSL source contains anything outside the flow-DSL allowlist,
36
+ # i.e. when it is not safe to `eval`. See Inquirex::SafeSource.
37
+ #
38
+ # Subclasses DefinitionError on purpose: hosts that already rescue that
39
+ # class and render the message keep working, and a rejected payload reads
40
+ # as "invalid DSL" rather than as a crash.
41
+ class UnsafeSourceError < DefinitionError
42
+ # @return [Array<String>] every violation found, most useful first
43
+ attr_reader :violations
44
+
45
+ # @param violations [Array<String>, String] human-readable violation messages
46
+ def initialize(violations)
47
+ @violations = Array(violations)
48
+ super("DSL rejected: #{@violations.join("; ")}")
49
+ end
50
+ end
51
+
52
+ # Raised when a SendEmail cannot build its message structurally,
53
+ # e.g. SendEmail#to_mail is called without the mail gem installed.
54
+ class SendEmailError < Error; end
38
55
  end
39
56
  end
data/lib/inquirex/node.rb CHANGED
@@ -40,6 +40,12 @@ module Inquirex
40
40
  enum multi_enum date email phone
41
41
  ].freeze
42
42
 
43
+ # Types for which {#min}, {#max} and {#step_size} carry meaning. Declaring
44
+ # a bound on anything else is a definition error rather than a silent
45
+ # no-op — a bounded `:string` almost always means the author picked the
46
+ # wrong type, and failing loudly is cheaper than shipping it.
47
+ BOUNDED_TYPES = %i[integer decimal currency].freeze
48
+
43
49
  attr_reader :id,
44
50
  :verb,
45
51
  :type,
@@ -51,6 +57,9 @@ module Inquirex
51
57
  :skip_if,
52
58
  :default,
53
59
  :required,
60
+ :min,
61
+ :max,
62
+ :step_size,
54
63
  :widget_hints,
55
64
  :accumulations
56
65
 
@@ -64,6 +73,9 @@ module Inquirex
64
73
  skip_if: nil,
65
74
  default: nil,
66
75
  required: true,
76
+ min: nil,
77
+ max: nil,
78
+ step_size: nil,
67
79
  widget_hints: nil,
68
80
  accumulations: [])
69
81
  @id = id.to_sym
@@ -75,17 +87,81 @@ module Inquirex
75
87
  @skip_if = skip_if
76
88
  @default = default
77
89
  @required = required ? true : false
90
+ @min = coerce_bound(min)
91
+ @max = coerce_bound(max)
92
+ @step_size = coerce_bound(step_size)
78
93
  @widget_hints = widget_hints&.freeze
79
94
  @accumulations = accumulations.freeze
80
95
  extract_options(options)
96
+ validate_bounds!
81
97
  freeze
82
98
  end
83
99
 
100
+ # Whether this step declares any numeric bound at all.
101
+ #
102
+ # @return [Boolean]
103
+ def bounded?
104
+ !@min.nil? || !@max.nil?
105
+ end
106
+
107
+ # Pulls a number inside this step's declared bounds.
108
+ #
109
+ # The wire format carries `min`/`max` so a renderer can *present* them, but
110
+ # an HTML number input does not stop a visitor typing past them and an LLM
111
+ # extraction has no notion of them at all. Every path that stores an answer
112
+ # for a bounded step goes through here, so the bound holds regardless of
113
+ # which client produced the value.
114
+ #
115
+ # @example
116
+ # node.clamp(900) # => 10, for a step declaring `max 10`
117
+ #
118
+ # @param value [Object] candidate answer
119
+ # @return [Object] the clamped Numeric, or the value unchanged when it is
120
+ # not numeric or the step declares no bounds
121
+ def clamp(value)
122
+ return value unless bounded?
123
+ return value unless value.is_a?(Numeric)
124
+
125
+ clamped = value
126
+ clamped = @min if @min && clamped < @min
127
+ clamped = @max if @max && clamped > @max
128
+ clamped
129
+ end
130
+
84
131
  # @return [Boolean] true if this step collects input from the user
85
132
  def collecting?
86
133
  COLLECTING_VERBS.include?(@verb)
87
134
  end
88
135
 
136
+ # Canonicalizes a raw value against this step's options: an exact value
137
+ # match wins, then a case-insensitive value match, then a case-insensitive
138
+ # label match — LLM extractions and humans often answer with the friendly
139
+ # label ("US citizen or permanent resident") when the form value is the
140
+ # canonical key ("us_person"). Matching is always resolved TO the form
141
+ # value, never the label. Steps without options return the value
142
+ # unchanged; an unmatchable value returns nil rather than polluting
143
+ # answers with junk that would satisfy not_empty rules.
144
+ #
145
+ # @example
146
+ # node.options # => ["us_person", "resident"]
147
+ # node.resolve_option("us_person") # => "us_person"
148
+ # node.resolve_option("US_PERSON") # => "us_person"
149
+ # node.resolve_option("US citizen or permanent resident") # => "us_person"
150
+ # node.resolve_option("alien overlord") # => nil
151
+ #
152
+ # @param raw [Object] candidate value (String, Symbol, ...)
153
+ # @return [String, Object, nil] the canonical option value; the raw value
154
+ # unchanged for steps without options; nil when nothing matches
155
+ def resolve_option(raw)
156
+ return raw if @options.nil? || @options.empty?
157
+ return nil if raw.nil?
158
+
159
+ candidate = raw.to_s
160
+ @options.find { |value| value == candidate } ||
161
+ @options.find { |value| value.casecmp?(candidate) } ||
162
+ @option_labels&.find { |_value, label| label.casecmp?(candidate) }&.first
163
+ end
164
+
89
165
  # @return [Boolean] true if this step only displays content (no input)
90
166
  def display?
91
167
  DISPLAY_VERBS.include?(@verb)
@@ -150,6 +226,9 @@ module Inquirex
150
226
  hash["skip_if"] = @skip_if.to_h if @skip_if
151
227
  hash["default"] = @default unless @default.nil? || @default.is_a?(Proc)
152
228
  hash["required"] = false unless @required
229
+ hash["min"] = @min unless @min.nil?
230
+ hash["max"] = @max unless @max.nil?
231
+ hash["step_size"] = @step_size unless @step_size.nil?
153
232
  elsif @text
154
233
  hash["text"] = @text
155
234
  end
@@ -186,6 +265,9 @@ module Inquirex
186
265
  default = hash["default"] || hash[:default]
187
266
  # Fetch chain (not ||) so an explicit false survives; absent key means required.
188
267
  required = hash.fetch("required") { hash.fetch(:required, true) }
268
+ min = hash["min"] || hash[:min]
269
+ max = hash["max"] || hash[:max]
270
+ step_size = hash["step_size"] || hash[:step_size]
189
271
  widget_data = hash["widget"] || hash[:widget]
190
272
  accumulate_data = hash["accumulate"] || hash[:accumulate]
191
273
 
@@ -206,6 +288,9 @@ module Inquirex
206
288
  skip_if:,
207
289
  default:,
208
290
  required:,
291
+ min:,
292
+ max:,
293
+ step_size:,
209
294
  widget_hints:,
210
295
  accumulations:
211
296
  )
@@ -213,6 +298,29 @@ module Inquirex
213
298
 
214
299
  private
215
300
 
301
+ # Numeric bounds arrive as Integers from Ruby DSL and as either Integer or
302
+ # Float from parsed JSON. Anything else — a String "10", a Symbol — is a
303
+ # definition error, not something to coerce quietly into 0.
304
+ def coerce_bound(value)
305
+ return nil if value.nil?
306
+ raise Errors::DefinitionError, "step #{@id}: numeric bound must be a number, got #{value.inspect}" unless value.is_a?(Numeric)
307
+
308
+ value
309
+ end
310
+
311
+ def validate_bounds!
312
+ return if @min.nil? && @max.nil? && @step_size.nil?
313
+
314
+ if @type && !BOUNDED_TYPES.include?(@type)
315
+ raise Errors::DefinitionError,
316
+ "step #{@id}: min/max/step_size only apply to #{BOUNDED_TYPES.join(", ")} steps, not #{@type}"
317
+ end
318
+
319
+ return unless @min && @max && @min > @max
320
+
321
+ raise Errors::DefinitionError, "step #{@id}: min (#{@min}) is greater than max (#{@max})"
322
+ end
323
+
216
324
  def extract_options(raw)
217
325
  case raw
218
326
  when Hash
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module SafeSource
5
+ # The shape a single allowlisted DSL call may take, built by
6
+ # {Vocabulary.allow} and consumed by {Validator}. Its three members:
7
+ #
8
+ # - `positional` — accepted positional arguments. An Array names each slot's
9
+ # kind in order (`[:symbol]`); `{ repeat: kind, min: n }` accepts any
10
+ # number of arguments of one kind; `{ optional: kind }` accepts zero or one.
11
+ # - `keywords` — `nil` when the call takes no keyword arguments, or a Hash
12
+ # mapping each accepted keyword to its value kind. The key
13
+ # {Vocabulary::ANY_OTHER} sets the kind for every keyword not named
14
+ # explicitly, and is only legitimate when the real method takes `**rest`.
15
+ # - `block` — `:forbidden`; the name of the nested scope whose vocabulary
16
+ # the block's statements are validated against; or `{ optional: scope }`
17
+ # for a call that accepts that block but does not require it, mirroring
18
+ # the `positional` spelling. `send_email` is the optional case: it takes
19
+ # its fields either as keywords or from a block.
20
+ #
21
+ # Value kinds are `:literal`, `:string`, `:symbol`, `:type_name` and `:rule`.
22
+ #
23
+ # @example The spec behind `transition to: :next, if_rule: equals(:a, 1)`
24
+ # CallSpec.new(positional: [],
25
+ # keywords: { to: :symbol, if_rule: :rule, requires_server: :literal },
26
+ # block: :forbidden)
27
+ CallSpec = Data.define(:positional, :keywords, :block) do
28
+ # The scope this call's block opens, with `{ optional: scope }`
29
+ # unwrapped, or nil when the call takes no block at all. Callers that
30
+ # care whether the block is required read {#block} itself.
31
+ #
32
+ # @return [Symbol, nil]
33
+ def block_scope
34
+ case block
35
+ when :forbidden then nil
36
+ when Hash then block[:optional]
37
+ else block
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end