Module: AgUi::A2ui::Validate

Defined in:
lib/ag_ui/a2ui/validate.rb

Overview

Semantic validation of A2UI v0.9 component trees — Ruby port of the protocol repo's a2ui_toolkit validate.py (itself a port of the TS a2ui-toolkit), kept behaviorally identical so this middleware and the sibling toolkits agree on what "valid" means.

Errors are plain hashes "path", "message" — JSON-friendly so they can ride straight into a prompt or event payload.

AgUi::A2ui.validate_components(components: [...], data: {...},
                             catalog: { "components" => {...} })
#=> { "valid" => false, "errors" => [{ "code" => ..., ... }] }

Constant Summary collapse

UNRESOLVED =
Object.new

Class Method Summary collapse

Class Method Details

.absolute_path_resolves?(path, data) ⇒ Boolean

Returns:

  • (Boolean)


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
# File 'lib/ag_ui/a2ui/validate.rb', line 167

def absolute_path_resolves?(path, data)
  segments = path.split("/").reject(&:empty?)
  resolved = segments.reduce(data) do |cursor, seg|
    case cursor
    when Array
      begin
        idx = Integer(seg, 10)
      rescue ArgumentError
        idx = nil
      end
      if idx.nil? || idx.negative? || idx >= cursor.length
        break UNRESOLVED
      end
      cursor[idx]
    when Hash
      unless cursor.key?(seg)
        break UNRESOLVED
      end
      cursor[seg]
    else
      break UNRESOLVED
    end
  end
  !resolved.equal?(UNRESOLVED)
end

.check_catalog(comp, index, catalog, catalog_components, errors) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/ag_ui/a2ui/validate.rb', line 112

def check_catalog(comp, index, catalog, catalog_components, errors)
  ctype = comp.is_a?(Hash) ? comp["component"] : nil
  if catalog && ctype.is_a?(String)
    schema = catalog_components[ctype]
    if schema.nil?
      errors << {
        "code" => "unknown_component",
        "path" => "components[#{index}].component",
        "message" => "Component type '#{ctype}' is not in the catalog",
      }
    else
      (schema["required"] || []).each do |req|
        unless comp.is_a?(Hash) && comp.key?(req)
          errors << {
            "code" => "missing_required_prop",
            "path" => "components[#{index}].#{req}",
            "message" => "Component '#{ctype}' (index #{index}) is missing required prop '#{req}'",
          }
        end
      end
    end
  end
end

.check_identity(comp, index, errors) ⇒ Object



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/ag_ui/a2ui/validate.rb', line 92

def check_identity(comp, index, errors)
  cid = comp.is_a?(Hash) ? comp["id"] : nil
  ctype = comp.is_a?(Hash) ? comp["component"] : nil

  unless cid.is_a?(String) && !cid.empty?
    errors << {
      "code" => "missing_id",
      "path" => "components[#{index}].id",
      "message" => "Component at index #{index} is missing a string 'id'",
    }
  end
  unless ctype.is_a?(String) && !ctype.empty?
    errors << {
      "code" => "missing_component_type",
      "path" => "components[#{index}].component",
      "message" => "Component at index #{index} is missing a string 'component' type",
    }
  end
end

.check_refs_and_bindings(comp, index, ids, catalog_components, data, validate_bindings, errors) ⇒ Object



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/ag_ui/a2ui/validate.rb', line 136

def check_refs_and_bindings(comp, index, ids, catalog_components, data, validate_bindings, errors)
  if comp.is_a?(Hash)
    ctype = comp["component"]
    schema = ctype.is_a?(String) ? catalog_components[ctype] : nil

    collect_component_ref_edges(comp, schema).each do |(ref_path, ref)|
      unless ids.include?(ref)
        errors << {
          "code" => "unresolved_child",
          "path" => "components[#{index}].#{ref_path}",
          "message" => "Child reference '#{ref}' does not match any component id",
        }
      end
    end

    if validate_bindings
      collect_absolute_binding_paths(comp, []).each do |path|
        unless absolute_path_resolves?(path, data || {})
          errors << {
            "code" => "unresolved_binding",
            "path" => "components[#{index}]",
            "message" => "Binding path '#{path}' does not resolve in the data model",
          }
        end
      end
    end
  end
end

.child_adjacency(components, catalog_components) ⇒ Object



291
292
293
294
295
296
297
298
299
300
301
# File 'lib/ag_ui/a2ui/validate.rb', line 291

def child_adjacency(components, catalog_components)
  adj = {}
  components.each do |comp|
    if comp.is_a?(Hash) && comp["id"].is_a?(String)
      ctype = comp["component"]
      schema = ctype.is_a?(String) ? catalog_components[ctype] : nil
      adj[comp["id"]] = collect_component_ref_edges(comp, schema).map { |(_p, ref)| ref }
    end
  end
  adj
end

.collect_absolute_binding_paths(node, acc) ⇒ Object



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/ag_ui/a2ui/validate.rb', line 351

def collect_absolute_binding_paths(node, acc)
  case node
  when Array
    node.each { |v| collect_absolute_binding_paths(v, acc) }
  when Hash
    p = node["path"]
    if p.is_a?(String) && p.start_with?("/")
      acc << p
    end
    node.each do |k, v|
      unless k == "path"
        collect_absolute_binding_paths(v, acc)
      end
    end
  end
  acc
end

.collect_array_item_edges(comp, field, prop_schema, edges) ⇒ Object



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/ag_ui/a2ui/validate.rb', line 258

def collect_array_item_edges(comp, field, prop_schema, edges)
  items = prop_schema["items"]
  item_props = items.is_a?(Hash) ? items["properties"] : nil
  arr_val = comp[field]
  if prop_schema["type"] == "array" && item_props.is_a?(Hash) && arr_val.is_a?(Array)
    arr_val.each_with_index do |item, k|
      unless item.is_a?(Hash)
        next
      end

      item_props.each do |sub, sub_schema|
        unless sub_schema.is_a?(Hash)
          next
        end

        case sub_schema["format"]
        when "componentRef"
          collect_child_refs(item[sub]).each { |ref| edges << ["#{field}[#{k}].#{sub}", ref] }
        when "componentRefList"
          sub_val = item[sub]
          if sub_val.is_a?(Array)
            sub_val.each_with_index do |sv, j|
              collect_child_refs(sv).each { |ref| edges << ["#{field}[#{k}].#{sub}[#{j}]", ref] }
            end
          else
            collect_child_refs(sub_val).each { |ref| edges << ["#{field}[#{k}].#{sub}", ref] }
          end
        end
      end
    end
  end
end

.collect_child_refs(children) ⇒ Object

A bare string id, or a ... template — the two child reference shapes.



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/ag_ui/a2ui/validate.rb', line 195

def collect_child_refs(children)
  refs = []
  push = ->(v) do
    if v.is_a?(String)
      refs << v
    elsif v.is_a?(Hash) && v["componentId"].is_a?(String)
      refs << v["componentId"]
    end
  end

  if children.is_a?(Array)
    children.each { |v| push.(v) }
  else
    push.(children)
  end
  refs
end

.collect_component_ref_edges(comp, schema) ⇒ Object

(path_suffix, ref_id) pairs for every child reference a component makes. Implicit child/children are ALWAYS refs (catalog-free behaviour preserved); other fields only when the catalog schema marks them "format": "componentRef" / "componentRefList" (with array-of-object item schemas honoured per element — finds Tabs tabItems.child). Unmarked props are data, never refs.



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/ag_ui/a2ui/validate.rb', line 219

def collect_component_ref_edges(comp, schema)
  edges = []

  push_single = ->(field, value) do
    collect_child_refs(value).each { |ref| edges << [field, ref] }
  end
  push_list = ->(field, value) do
    if value.is_a?(Array)
      value.each_with_index do |item, k|
        collect_child_refs(item).each { |ref| edges << ["#{field}[#{k}]", ref] }
      end
    else
      collect_child_refs(value).each { |ref| edges << [field, ref] }
    end
  end

  push_single.("child", comp["child"])
  push_list.("children", comp["children"])

  props = schema.is_a?(Hash) ? schema["properties"] : nil
  if props.is_a?(Hash)
    props.each do |field, prop_schema|
      if %w[child children].include?(field) || !prop_schema.is_a?(Hash)
        next
      end

      case prop_schema["format"]
      when "componentRef"
        push_single.(field, comp[field])
      when "componentRefList"
        push_list.(field, comp[field])
      else
        collect_array_item_edges(comp, field, prop_schema, edges)
      end
    end
  end
  edges
end

.collect_ids(components, errors) ⇒ Object



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/ag_ui/a2ui/validate.rb', line 72

def collect_ids(components, errors)
  ids = Set.new
  seen = Set.new
  components.each do |comp|
    cid = comp.is_a?(Hash) ? comp["id"] : nil
    if cid.is_a?(String)
      if seen.include?(cid)
        errors << {
          "code" => "duplicate_id",
          "path" => "components[id=#{cid}]",
          "message" => "Duplicate component id '#{cid}'",
        }
      end
      seen << cid
      ids << cid
    end
  end
  ids
end

.find_child_cycles(components, catalog_components) ⇒ Object

Unique child-reference cycles via ITERATIVE DFS (explicit frame stack) — the validator runs on untrusted model output, so a pathologically deep chain must not blow the Ruby stack. Cycles are canonicalised (smallest id leads) so the same loop reached from different entry points collapses to one finding.



308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/ag_ui/a2ui/validate.rb', line 308

def find_child_cycles(components, catalog_components)
  adj = child_adjacency(components, catalog_components)
  color = {}   # absent = unvisited, 1 = on stack, 2 = done
  cycles = {}

  canonical = ->(nodes) do
    m = (0...nodes.length).min_by { |i| nodes[i] }
    nodes[m..] + nodes[...m]
  end

  adj.each_key do |root|
    if color.key?(root)
      next
    end

    frames = [[root, 0]]
    path = [root]
    color[root] = 1
    until frames.empty?
      node, i = frames.last
      neighbors = adj[node] || []
      if i >= neighbors.length
        color[node] = 2
        frames.pop
        path.pop
        next
      end
      frames.last[1] += 1
      v = neighbors[i]
      case color[v]
      when nil
        color[v] = 1
        path << v
        frames << [v, 0]
      when 1
        cyc = canonical.(path[path.index(v)..])
        cycles[cyc.join(" ")] ||= cyc
      end
    end
  end
  cycles.values
end

.validate_components(components:, data: nil, catalog: nil, validate_bindings: true) ⇒ Object

Structural checks always run. Catalog membership + required-prop checks run only when a catalog is supplied. Absolute binding paths ("/foo") resolve against data; relative template paths ("name") are left alone — they resolve per-item inside repeated templates.



26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/ag_ui/a2ui/validate.rb', line 26

def validate_components(components:, data: nil, catalog: nil, validate_bindings: true)
  if !components.is_a?(Array) || components.empty?
    {
      "valid" => false,
      "errors" => [{
        "code" => "empty_components",
        "path" => "components",
        "message" => "A2UI components must be a non-empty array",
      }],
    }
  else
    validate_populated(components, data, catalog, validate_bindings)
  end
end

.validate_populated(components, data, catalog, validate_bindings) ⇒ Object



41
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
67
68
69
70
# File 'lib/ag_ui/a2ui/validate.rb', line 41

def validate_populated(components, data, catalog, validate_bindings)
  errors = []
  ids = collect_ids(components, errors)
  catalog_components = catalog.is_a?(Hash) ? (catalog["components"] || {}) : {}

  components.each_with_index do |comp, i|
    check_identity(comp, i, errors)
    check_catalog(comp, i, catalog, catalog_components, errors)
    check_refs_and_bindings(comp, i, ids, catalog_components, data, validate_bindings, errors)
  end

  find_child_cycles(components, catalog_components).each do |cycle|
    chain = (cycle + [cycle.first]).join(" -> ")
    errors << {
      "code" => "child_cycle",
      "path" => "components[id=#{cycle.first}]",
      "message" => "Child reference cycle detected: #{chain}",
    }
  end

  unless components.any? { |c| c.is_a?(Hash) && c["id"] == "root" }
    errors << {
      "code" => "no_root",
      "path" => "components",
      "message" => "No component has id 'root'",
    }
  end

  { "valid" => errors.empty?, "errors" => errors }
end