Module: Inquirex::SafeSource::Vocabulary

Defined in:
lib/inquirex/safe_source/vocabulary.rb

Overview

The registry of DSL words Validator accepts, and of the words it deliberately refuses.

Every entry is bound to the builder that implements it: the six flow verbs are derived from Node::VERBS, step types from Node::TYPES, the rule vocabulary from DSL::RuleHelpers, and each scope declares the method list it governs (vocabulary:) so that Vocabulary.undeclared_names can name any builder method nobody has made an allowlist decision about. spec/inquirex/safe_source/vocabulary_spec.rb fails the build when that list is non-empty — which is what stops this table drifting away from the DSL it is supposed to describe.

Downstream gems extend the vocabulary at boot rather than forking it.

Examples:

Teaching the allowlist about inquirex-llm's verbs

V = Inquirex::SafeSource::Vocabulary
V.register_scope(:llm_step, label: "an LLM step",
  vocabulary: -> { Inquirex::LLM::DSL::StepBuilder.public_instance_methods(false) })
V.allow(:flow, :clarify, positional: %i[symbol], block: :llm_step)
V.allow(:llm_step, :prompt, positional: %i[literal])
V.exclude(:llm_step, :fallback, "a Ruby block cannot be validated")

Constant Summary collapse

ENTRY_CONSTANT =

The only constant a flow definition may name.

:Inquirex
ENTRY_METHOD =

The only method that constant may receive.

:define
ANY_OTHER =

Keyword-descriptor key standing for "every keyword not named explicitly". Legitimate only where the real method takes **rest.

:*
TRANSITION_KEYWORDS =

Value kinds a transition accepts. Shared by every step-like scope.

{ to: :symbol, if_rule: :rule, requires_server: :literal }.freeze
EMAIL_FIELD_KEYWORDS =

The send_email fields, and the shape each accepts. One definition serves both DSL forms — the flow-level keywords (send_email to: ...) and the block setters (send_email do to "..." end) — so the two can never drift apart. headers is :literal because it is a Hash; every other field is one static string.

{
  to:            :string,
  from:          :string,
  cc:            :string,
  bcc:           :string,
  reply_to:      :string,
  subject:       :string,
  text:          :string,
  markdown_text: :string,
  html:          :string,
  headers:       :literal
}.freeze
PLUMBING =

Builder methods that exist for the builder's own sake and are not DSL words, so they need no allowlist decision. params is SendEmailBuilder's accumulator, read by FlowBuilder once the block has run — nothing a flow author writes.

%i[build params method_missing respond_to_missing?].freeze

Class Method Summary collapse

Class Method Details

.allow(scope, name, positional: [], keywords: nil, block: :forbidden) ⇒ CallSpec

Allowlists one call inside one scope.

Parameters:

  • scope (Symbol)

    scope name, e.g. :step

  • name (Symbol)

    DSL word, e.g. :question

  • positional (Array<Symbol>, Hash) (defaults to: [])
  • keywords (nil, Hash) (defaults to: nil)
  • block (Symbol) (defaults to: :forbidden)

    :forbidden, or the scope its block opens

Returns:

Raises:

  • (ArgumentError)

    when the scope was never registered



92
93
94
95
# File 'lib/inquirex/safe_source/vocabulary.rb', line 92

def allow(scope, name, positional: [], keywords: nil, block: :forbidden)
  table = @calls.fetch(scope.to_sym) { raise ArgumentError, "Unknown SafeSource scope: #{scope.inspect}" }
  table[name.to_sym] = CallSpec.new(positional:, keywords:, block:)
end

.allowed_names(scope) ⇒ Array<Symbol>

Returns every allowlisted call in the scope.

Parameters:

  • scope (Symbol)

Returns:

  • (Array<Symbol>)

    every allowlisted call in the scope



145
146
147
# File 'lib/inquirex/safe_source/vocabulary.rb', line 145

def allowed_names(scope)
  @calls.fetch(scope.to_sym, {}).keys
end

.entry_specCallSpec

The spec for Inquirex.define itself.

Returns:



192
193
194
# File 'lib/inquirex/safe_source/vocabulary.rb', line 192

def entry_spec
  CallSpec.new(positional: [], keywords: { id: :string, version: :string }, block: :flow)
end

.exclude(scope, name, reason) ⇒ String

Records a DSL word that is knowingly not allowlisted, with the reason — which Inquirex::SafeSource::Validator quotes back to the author, so a rejection reads as a decision rather than an oversight.

Parameters:

  • scope (Symbol)

    scope name

  • name (Symbol)

    DSL word, e.g. :compute

  • reason (String)

    why safe mode cannot accept it

Returns:

  • (String)

    the reason

Raises:

  • (ArgumentError)

    when the scope was never registered



106
107
108
109
# File 'lib/inquirex/safe_source/vocabulary.rb', line 106

def exclude(scope, name, reason)
  table = @exclusions.fetch(scope.to_sym) { raise ArgumentError, "Unknown SafeSource scope: #{scope.inspect}" }
  table[name.to_sym] = reason
end

.excluded_names(scope) ⇒ Array<Symbol>

Returns every knowingly excluded call in the scope.

Parameters:

  • scope (Symbol)

Returns:

  • (Array<Symbol>)

    every knowingly excluded call in the scope



151
152
153
# File 'lib/inquirex/safe_source/vocabulary.rb', line 151

def excluded_names(scope)
  @exclusions.fetch(scope.to_sym, {}).keys
end

.exclusion_for(scope, name) ⇒ String?

Returns why the call is excluded, nil when it was never part of the scope's vocabulary at all.

Parameters:

  • scope (Symbol)
  • name (Symbol)

Returns:

  • (String, nil)

    why the call is excluded, nil when it was never part of the scope's vocabulary at all



122
123
124
# File 'lib/inquirex/safe_source/vocabulary.rb', line 122

def exclusion_for(scope, name)
  @exclusions.fetch(scope.to_sym, {})[name.to_sym]
end

.governed_names(scope) ⇒ Array<Symbol>

The DSL words the scope's builder implements, per the vocabulary: binding given to register_scope, minus builder plumbing.

Parameters:

  • scope (Symbol)

Returns:

  • (Array<Symbol>)

    empty when the scope declared no binding



160
161
162
# File 'lib/inquirex/safe_source/vocabulary.rb', line 160

def governed_names(scope)
  @vocabularies.fetch(scope.to_sym, nil)&.call.to_a.map(&:to_sym) - PLUMBING
end

.label_for(scope) ⇒ String

Returns the scope's human-readable label, article included.

Parameters:

  • scope (Symbol)

Returns:

  • (String)

    the scope's human-readable label, article included



128
129
130
# File 'lib/inquirex/safe_source/vocabulary.rb', line 128

def label_for(scope)
  @labels.fetch(scope.to_sym, "an unknown")
end

.register_scope(name, label:, vocabulary: nil) ⇒ void

This method returns an undefined value.

Declares a nested scope (idempotent): a block whose statements are validated against their own table of calls.

Parameters:

  • name (Symbol)

    scope name, e.g. :step

  • label (String)

    how to name the scope in a violation message, article included ("a step")

  • vocabulary (Proc, nil) (defaults to: nil)

    returns the DSL words the scope's builder really implements; used by undeclared_names to detect drift. Lazy, so load order does not matter.



75
76
77
78
79
80
81
# File 'lib/inquirex/safe_source/vocabulary.rb', line 75

def register_scope(name, label:, vocabulary: nil)
  sym = name.to_sym
  @labels[sym] = label
  @vocabularies[sym] = vocabulary
  @calls[sym] ||= {}
  @exclusions[sym] ||= {}
end

.reset!void

This method returns an undefined value.

Discards every registration and reinstalls the core vocabulary. Hosts call this to undo an extension; the specs call it to isolate.



200
201
202
203
204
205
206
# File 'lib/inquirex/safe_source/vocabulary.rb', line 200

def reset!
  @labels = {}
  @calls = {}
  @exclusions = {}
  @vocabularies = {}
  install_core!
end

.scope?(name) ⇒ Boolean

Returns whether the scope has been registered.

Parameters:

  • name (Symbol)

Returns:

  • (Boolean)

    whether the scope has been registered



134
135
136
# File 'lib/inquirex/safe_source/vocabulary.rb', line 134

def scope?(name)
  @labels.key?(name.to_sym)
end

.scopesArray<Symbol>

Returns every registered scope.

Returns:

  • (Array<Symbol>)

    every registered scope



139
140
141
# File 'lib/inquirex/safe_source/vocabulary.rb', line 139

def scopes
  @labels.keys
end

.spec_for(scope, name) ⇒ CallSpec?

Returns nil when the call is not allowlisted.

Parameters:

  • scope (Symbol)
  • name (Symbol)

Returns:

  • (CallSpec, nil)

    nil when the call is not allowlisted



114
115
116
# File 'lib/inquirex/safe_source/vocabulary.rb', line 114

def spec_for(scope, name)
  @calls.fetch(scope.to_sym, {})[name.to_sym]
end

.stale_names(scope) ⇒ Array<Symbol>

Allowlist entries that no longer correspond to a real builder method — a typo, or a DSL word that has since been removed or renamed.

Parameters:

  • scope (Symbol)

Returns:

  • (Array<Symbol>)


183
184
185
186
187
# File 'lib/inquirex/safe_source/vocabulary.rb', line 183

def stale_names(scope)
  return [] if @vocabularies.fetch(scope.to_sym, nil).nil?

  (allowed_names(scope) + excluded_names(scope)) - governed_names(scope)
end

.undeclared_names(scope) ⇒ Array<Symbol>

Builder methods with no allowlist decision: neither allowed nor knowingly excluded. Anything here is a new DSL word that silently became unusable in safe mode (or, worse, an old one that quietly gained a new meaning).

Examples:

Fail fast at boot

raise "unreviewed DSL words" if V.scopes.any? { |s| V.undeclared_names(s).any? }

Parameters:

  • scope (Symbol)

Returns:

  • (Array<Symbol>)


174
175
176
# File 'lib/inquirex/safe_source/vocabulary.rb', line 174

def undeclared_names(scope)
  governed_names(scope) - allowed_names(scope) - excluded_names(scope)
end