Module: Textus::Contract::Binder

Defined in:
lib/textus/contract/binder.rb

Overview

The single argument binder for every surface (spike: collapses the three historical implementations — MCP::Catalog.map_args, CLI::Runner.call_args, and RoleScope’s default-injection loop — into one algorithm).

Input is a uniform ‘inputs` hash keyed by arg NAME (the use-case kwarg name, never the wire name): each surface normalizes its own raw transport shape (MCP JSON keyed by wire-name, CLI argv+flags, Ruby args+kwargs) into this hash. Binder owns the shared algorithm and nothing transport-specific:

1. validate every required arg is present in `inputs`;
2. for absentees, fall back to session_default (when a session is given)
   then to the literal default; otherwise omit the arg entirely;
3. split into the (positional, keyword) pair to splat into the use-case,
   routing by `arg.positional`.

Returns ‘[positional_array, keyword_hash]` — exactly what `RoleScope#<verb>(*pos, **kw)` expects.

Class Method Summary collapse

Class Method Details

.bind(spec, inputs, session: nil) ⇒ Object

Validation is unconditional: a ‘required:` arg absent from `inputs` is a contract violation on every surface (ADR 0069). `required:` is now an honest contract invariant, not a surface policy — args the use-case treats as optional (e.g. `meta`, whose real requiredness lives in schema validation downstream) are declared `required: false`, so this check never fires spuriously and never needs an opt-out.

Raises:



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/textus/contract/binder.rb', line 42

def bind(spec, inputs, session: nil)
  missing = spec.required_args.reject { |a| inputs.key?(a.name) }
  raise MissingArgs.new(spec, missing) unless missing.empty?

  pos = []
  kw  = {}
  spec.args.each do |a|
    if inputs.key?(a.name)
      value = inputs[a.name]
    elsif a.session_default && session
      value = session.public_send(a.session_default)
    elsif !a.default.nil?
      value = a.default
    else
      next
    end

    if a.positional
      pos << value
    else
      kw[a.name] = value
    end
  end
  [pos, kw]
end

.inputs_from_ordered(spec, ordered_positionals, by_name_keywords) ⇒ Object

Normalize an ordered positional list + a by-name keyword hash (the shape CLI argv+flags and Ruby args+kwargs both arrive in) into the uniform by-name ‘inputs` hash bind expects. Positionals beyond what was supplied are dropped so bind’s required-check sees them as absent.



72
73
74
75
# File 'lib/textus/contract/binder.rb', line 72

def inputs_from_ordered(spec, ordered_positionals, by_name_keywords)
  names = spec.args.select(&:positional).map(&:name)
  names.zip(ordered_positionals).to_h.compact.merge(by_name_keywords)
end

.inputs_from_wire(spec, raw) ⇒ Object

Normalize a raw transport hash keyed by WIRE name (the shape MCP JSON arrives in) into the uniform by-name ‘inputs` hash bind expects. Keys not declared on the contract are ignored.



80
81
82
83
84
85
# File 'lib/textus/contract/binder.rb', line 80

def inputs_from_wire(spec, raw)
  raw ||= {}
  spec.args.each_with_object({}) do |a, h|
    h[a.name] = raw[a.wire.to_s] if raw.key?(a.wire.to_s)
  end
end