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)


211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/ag_ui/a2ui/validate.rb', line 211

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



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/ag_ui/a2ui/validate.rb', line 148

def check_catalog(comp, index, catalog, catalog_components, errors)
  if comp.is_a?(Hash)
    ctype = comp["component"]
  else
    ctype = nil
  end
  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



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/ag_ui/a2ui/validate.rb', line 120

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

  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



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/ag_ui/a2ui/validate.rb', line 176

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

    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



348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/ag_ui/a2ui/validate.rb', line 348

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

.collect_absolute_binding_paths(node, acc) ⇒ Object



412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/ag_ui/a2ui/validate.rb', line 412

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



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

def collect_array_item_edges(comp, field, prop_schema, edges)
  items = prop_schema["items"]
  if items.is_a?(Hash)
    item_props = items["properties"]
  else
    item_props = nil
  end
  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.



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/ag_ui/a2ui/validate.rb', line 239

def collect_child_refs(children)
  [].tap do |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
  end
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.



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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/ag_ui/a2ui/validate.rb', line 263

def collect_component_ref_edges(comp, schema)
  [].tap do |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"])

    if schema.is_a?(Hash)
      props = schema["properties"]
    else
      props = nil
    end
    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
  end
end

.collect_ids(components, errors) ⇒ Object



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/ag_ui/a2ui/validate.rb', line 96

def collect_ids(components, errors)
  Set.new.tap do |ids|
    seen = Set.new
    components.each do |comp|
      if comp.is_a?(Hash)
        cid = comp["id"]
      else
        cid = nil
      end
      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
  end
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.



369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# File 'lib/ag_ui/a2ui/validate.rb', line 369

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
40
41
42
43
44
45
# 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



47
48
49
50
51
52
53
54
55
56
57
58
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
87
88
89
90
91
92
93
94
# File 'lib/ag_ui/a2ui/validate.rb', line 47

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

  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