ag-ui 0.1.0

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,616 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "ag_ui"
5
+
6
+ module AgUi
7
+ module A2ui
8
+ # Semantic validation of A2UI v0.9 component trees — Ruby port of the
9
+ # protocol repo's a2ui_toolkit validate.py (itself a port of the TS
10
+ # a2ui-toolkit), kept behaviorally identical so this middleware and the
11
+ # sibling toolkits agree on what "valid" means.
12
+ #
13
+ # Errors are plain hashes {"code", "path", "message"} — JSON-friendly so
14
+ # they can ride straight into a prompt or event payload.
15
+ #
16
+ # AgUi::A2ui.validate_components(components: [...], data: {...},
17
+ # catalog: { "components" => {...} })
18
+ # #=> { "valid" => false, "errors" => [{ "code" => ..., ... }] }
19
+ module Validate
20
+ module_function
21
+
22
+ # Structural checks always run. Catalog membership + required-prop
23
+ # checks run only when a catalog is supplied. Absolute binding paths
24
+ # ("/foo") resolve against data; relative template paths ("name")
25
+ # are left alone — they resolve per-item inside repeated templates.
26
+ def validate_components(components:, data: nil, catalog: nil, validate_bindings: true)
27
+ if !components.is_a?(Array) || components.empty?
28
+ {
29
+ "valid" => false,
30
+ "errors" => [{
31
+ "code" => "empty_components",
32
+ "path" => "components",
33
+ "message" => "A2UI components must be a non-empty array",
34
+ }],
35
+ }
36
+ else
37
+ validate_populated(components, data, catalog, validate_bindings)
38
+ end
39
+ end
40
+
41
+ def validate_populated(components, data, catalog, validate_bindings)
42
+ errors = []
43
+ ids = collect_ids(components, errors)
44
+ catalog_components = catalog.is_a?(Hash) ? (catalog["components"] || {}) : {}
45
+
46
+ components.each_with_index do |comp, i|
47
+ check_identity(comp, i, errors)
48
+ check_catalog(comp, i, catalog, catalog_components, errors)
49
+ check_refs_and_bindings(comp, i, ids, catalog_components, data, validate_bindings, errors)
50
+ end
51
+
52
+ find_child_cycles(components, catalog_components).each do |cycle|
53
+ chain = (cycle + [cycle.first]).join(" -> ")
54
+ errors << {
55
+ "code" => "child_cycle",
56
+ "path" => "components[id=#{cycle.first}]",
57
+ "message" => "Child reference cycle detected: #{chain}",
58
+ }
59
+ end
60
+
61
+ unless components.any? { |c| c.is_a?(Hash) && c["id"] == "root" }
62
+ errors << {
63
+ "code" => "no_root",
64
+ "path" => "components",
65
+ "message" => "No component has id 'root'",
66
+ }
67
+ end
68
+
69
+ { "valid" => errors.empty?, "errors" => errors }
70
+ end
71
+
72
+ def collect_ids(components, errors)
73
+ ids = Set.new
74
+ seen = Set.new
75
+ components.each do |comp|
76
+ cid = comp.is_a?(Hash) ? comp["id"] : nil
77
+ if cid.is_a?(String)
78
+ if seen.include?(cid)
79
+ errors << {
80
+ "code" => "duplicate_id",
81
+ "path" => "components[id=#{cid}]",
82
+ "message" => "Duplicate component id '#{cid}'",
83
+ }
84
+ end
85
+ seen << cid
86
+ ids << cid
87
+ end
88
+ end
89
+ ids
90
+ end
91
+
92
+ def check_identity(comp, index, errors)
93
+ cid = comp.is_a?(Hash) ? comp["id"] : nil
94
+ ctype = comp.is_a?(Hash) ? comp["component"] : nil
95
+
96
+ unless cid.is_a?(String) && !cid.empty?
97
+ errors << {
98
+ "code" => "missing_id",
99
+ "path" => "components[#{index}].id",
100
+ "message" => "Component at index #{index} is missing a string 'id'",
101
+ }
102
+ end
103
+ unless ctype.is_a?(String) && !ctype.empty?
104
+ errors << {
105
+ "code" => "missing_component_type",
106
+ "path" => "components[#{index}].component",
107
+ "message" => "Component at index #{index} is missing a string 'component' type",
108
+ }
109
+ end
110
+ end
111
+
112
+ def check_catalog(comp, index, catalog, catalog_components, errors)
113
+ ctype = comp.is_a?(Hash) ? comp["component"] : nil
114
+ if catalog && ctype.is_a?(String)
115
+ schema = catalog_components[ctype]
116
+ if schema.nil?
117
+ errors << {
118
+ "code" => "unknown_component",
119
+ "path" => "components[#{index}].component",
120
+ "message" => "Component type '#{ctype}' is not in the catalog",
121
+ }
122
+ else
123
+ (schema["required"] || []).each do |req|
124
+ unless comp.is_a?(Hash) && comp.key?(req)
125
+ errors << {
126
+ "code" => "missing_required_prop",
127
+ "path" => "components[#{index}].#{req}",
128
+ "message" => "Component '#{ctype}' (index #{index}) is missing required prop '#{req}'",
129
+ }
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ def check_refs_and_bindings(comp, index, ids, catalog_components, data, validate_bindings, errors)
137
+ if comp.is_a?(Hash)
138
+ ctype = comp["component"]
139
+ schema = ctype.is_a?(String) ? catalog_components[ctype] : nil
140
+
141
+ collect_component_ref_edges(comp, schema).each do |(ref_path, ref)|
142
+ unless ids.include?(ref)
143
+ errors << {
144
+ "code" => "unresolved_child",
145
+ "path" => "components[#{index}].#{ref_path}",
146
+ "message" => "Child reference '#{ref}' does not match any component id",
147
+ }
148
+ end
149
+ end
150
+
151
+ if validate_bindings
152
+ collect_absolute_binding_paths(comp, []).each do |path|
153
+ unless absolute_path_resolves?(path, data || {})
154
+ errors << {
155
+ "code" => "unresolved_binding",
156
+ "path" => "components[#{index}]",
157
+ "message" => "Binding path '#{path}' does not resolve in the data model",
158
+ }
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ UNRESOLVED = Object.new
166
+
167
+ def absolute_path_resolves?(path, data)
168
+ segments = path.split("/").reject(&:empty?)
169
+ resolved = segments.reduce(data) do |cursor, seg|
170
+ case cursor
171
+ when Array
172
+ begin
173
+ idx = Integer(seg, 10)
174
+ rescue ArgumentError
175
+ idx = nil
176
+ end
177
+ if idx.nil? || idx.negative? || idx >= cursor.length
178
+ break UNRESOLVED
179
+ end
180
+ cursor[idx]
181
+ when Hash
182
+ unless cursor.key?(seg)
183
+ break UNRESOLVED
184
+ end
185
+ cursor[seg]
186
+ else
187
+ break UNRESOLVED
188
+ end
189
+ end
190
+ !resolved.equal?(UNRESOLVED)
191
+ end
192
+
193
+ # A bare string id, or a {componentId: ...} template — the two child
194
+ # reference shapes.
195
+ def collect_child_refs(children)
196
+ refs = []
197
+ push = ->(v) do
198
+ if v.is_a?(String)
199
+ refs << v
200
+ elsif v.is_a?(Hash) && v["componentId"].is_a?(String)
201
+ refs << v["componentId"]
202
+ end
203
+ end
204
+
205
+ if children.is_a?(Array)
206
+ children.each { |v| push.(v) }
207
+ else
208
+ push.(children)
209
+ end
210
+ refs
211
+ end
212
+
213
+ # (path_suffix, ref_id) pairs for every child reference a component
214
+ # makes. Implicit `child`/`children` are ALWAYS refs (catalog-free
215
+ # behaviour preserved); other fields only when the catalog schema
216
+ # marks them "format": "componentRef" / "componentRefList" (with
217
+ # array-of-object item schemas honoured per element — finds Tabs
218
+ # tabItems[].child). Unmarked props are data, never refs.
219
+ def collect_component_ref_edges(comp, schema)
220
+ edges = []
221
+
222
+ push_single = ->(field, value) do
223
+ collect_child_refs(value).each { |ref| edges << [field, ref] }
224
+ end
225
+ push_list = ->(field, value) do
226
+ if value.is_a?(Array)
227
+ value.each_with_index do |item, k|
228
+ collect_child_refs(item).each { |ref| edges << ["#{field}[#{k}]", ref] }
229
+ end
230
+ else
231
+ collect_child_refs(value).each { |ref| edges << [field, ref] }
232
+ end
233
+ end
234
+
235
+ push_single.("child", comp["child"])
236
+ push_list.("children", comp["children"])
237
+
238
+ props = schema.is_a?(Hash) ? schema["properties"] : nil
239
+ if props.is_a?(Hash)
240
+ props.each do |field, prop_schema|
241
+ if %w[child children].include?(field) || !prop_schema.is_a?(Hash)
242
+ next
243
+ end
244
+
245
+ case prop_schema["format"]
246
+ when "componentRef"
247
+ push_single.(field, comp[field])
248
+ when "componentRefList"
249
+ push_list.(field, comp[field])
250
+ else
251
+ collect_array_item_edges(comp, field, prop_schema, edges)
252
+ end
253
+ end
254
+ end
255
+ edges
256
+ end
257
+
258
+ def collect_array_item_edges(comp, field, prop_schema, edges)
259
+ items = prop_schema["items"]
260
+ item_props = items.is_a?(Hash) ? items["properties"] : nil
261
+ arr_val = comp[field]
262
+ if prop_schema["type"] == "array" && item_props.is_a?(Hash) && arr_val.is_a?(Array)
263
+ arr_val.each_with_index do |item, k|
264
+ unless item.is_a?(Hash)
265
+ next
266
+ end
267
+
268
+ item_props.each do |sub, sub_schema|
269
+ unless sub_schema.is_a?(Hash)
270
+ next
271
+ end
272
+
273
+ case sub_schema["format"]
274
+ when "componentRef"
275
+ collect_child_refs(item[sub]).each { |ref| edges << ["#{field}[#{k}].#{sub}", ref] }
276
+ when "componentRefList"
277
+ sub_val = item[sub]
278
+ if sub_val.is_a?(Array)
279
+ sub_val.each_with_index do |sv, j|
280
+ collect_child_refs(sv).each { |ref| edges << ["#{field}[#{k}].#{sub}[#{j}]", ref] }
281
+ end
282
+ else
283
+ collect_child_refs(sub_val).each { |ref| edges << ["#{field}[#{k}].#{sub}", ref] }
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
290
+
291
+ def child_adjacency(components, catalog_components)
292
+ adj = {}
293
+ components.each do |comp|
294
+ if comp.is_a?(Hash) && comp["id"].is_a?(String)
295
+ ctype = comp["component"]
296
+ schema = ctype.is_a?(String) ? catalog_components[ctype] : nil
297
+ adj[comp["id"]] = collect_component_ref_edges(comp, schema).map { |(_p, ref)| ref }
298
+ end
299
+ end
300
+ adj
301
+ end
302
+
303
+ # Unique child-reference cycles via ITERATIVE DFS (explicit frame
304
+ # stack) — the validator runs on untrusted model output, so a
305
+ # pathologically deep chain must not blow the Ruby stack. Cycles are
306
+ # canonicalised (smallest id leads) so the same loop reached from
307
+ # different entry points collapses to one finding.
308
+ def find_child_cycles(components, catalog_components)
309
+ adj = child_adjacency(components, catalog_components)
310
+ color = {} # absent = unvisited, 1 = on stack, 2 = done
311
+ cycles = {}
312
+
313
+ canonical = ->(nodes) do
314
+ m = (0...nodes.length).min_by { |i| nodes[i] }
315
+ nodes[m..] + nodes[...m]
316
+ end
317
+
318
+ adj.each_key do |root|
319
+ if color.key?(root)
320
+ next
321
+ end
322
+
323
+ frames = [[root, 0]]
324
+ path = [root]
325
+ color[root] = 1
326
+ until frames.empty?
327
+ node, i = frames.last
328
+ neighbors = adj[node] || []
329
+ if i >= neighbors.length
330
+ color[node] = 2
331
+ frames.pop
332
+ path.pop
333
+ next
334
+ end
335
+ frames.last[1] += 1
336
+ v = neighbors[i]
337
+ case color[v]
338
+ when nil
339
+ color[v] = 1
340
+ path << v
341
+ frames << [v, 0]
342
+ when 1
343
+ cyc = canonical.(path[path.index(v)..])
344
+ cycles[cyc.join(" ")] ||= cyc
345
+ end
346
+ end
347
+ end
348
+ cycles.values
349
+ end
350
+
351
+ def collect_absolute_binding_paths(node, acc)
352
+ case node
353
+ when Array
354
+ node.each { |v| collect_absolute_binding_paths(v, acc) }
355
+ when Hash
356
+ p = node["path"]
357
+ if p.is_a?(String) && p.start_with?("/")
358
+ acc << p
359
+ end
360
+ node.each do |k, v|
361
+ unless k == "path"
362
+ collect_absolute_binding_paths(v, acc)
363
+ end
364
+ end
365
+ end
366
+ acc
367
+ end
368
+ end
369
+
370
+ # Public entrypoint, mirroring the toolkit's validate_a2ui_components.
371
+ def self.validate_components(...)
372
+ Validate.validate_components(...)
373
+ end
374
+ end
375
+ end
376
+
377
+ __END__
378
+
379
+ describe "AgUi::A2ui.validate_components" do
380
+ # Fixtures ported verbatim from the toolkit's test_validate.py.
381
+ catalog = {
382
+ "components" => {
383
+ "Row" => { "type" => "object", "required" => ["children"] },
384
+ "HotelCard" => {
385
+ "type" => "object",
386
+ "required" => %w[name location rating pricePerNight],
387
+ },
388
+ },
389
+ }
390
+
391
+ valid_components = -> do
392
+ [
393
+ { "id" => "root", "component" => "Row",
394
+ "children" => { "componentId" => "card", "path" => "/items" } },
395
+ { "id" => "card", "component" => "HotelCard",
396
+ "name" => { "path" => "name" }, "location" => { "path" => "location" },
397
+ "rating" => { "path" => "rating" }, "pricePerNight" => { "path" => "pricePerNight" } },
398
+ ]
399
+ end
400
+
401
+ valid_data = {
402
+ "items" => [{ "name" => "Ritz", "location" => "NYC", "rating" => 4.8, "pricePerNight" => "$450" }],
403
+ }
404
+
405
+ codes = ->(result) { result["errors"].map { |e| e["code"] } }
406
+
407
+ it "accepts a well-formed surface" do
408
+ r = AgUi::A2ui.validate_components(components: valid_components.(), data: valid_data, catalog: catalog)
409
+ r["valid"].should == true
410
+ r["errors"].should == []
411
+ end
412
+
413
+ it "flags a missing root" do
414
+ comps = valid_components.().map { |c| c["id"] == "root" ? c.merge("id" => "container") : c }
415
+ r = AgUi::A2ui.validate_components(components: comps, data: valid_data, catalog: catalog)
416
+ r["valid"].should == false
417
+ codes.(r).should.include?("no_root")
418
+ end
419
+
420
+ it "flags missing id / component type / duplicate ids" do
421
+ codes.(AgUi::A2ui.validate_components(components: [{ "component" => "Row", "children" => [] }]))
422
+ .should.include?("missing_id")
423
+ codes.(AgUi::A2ui.validate_components(components: [{ "id" => "root" }]))
424
+ .should.include?("missing_component_type")
425
+
426
+ comps = [
427
+ { "id" => "root", "component" => "Row", "children" => ["x"] },
428
+ { "id" => "x", "component" => "Row", "children" => [] },
429
+ { "id" => "x", "component" => "Row", "children" => [] },
430
+ ]
431
+ codes.(AgUi::A2ui.validate_components(components: comps)).should.include?("duplicate_id")
432
+ end
433
+
434
+ it "fails loud on empty or non-array payloads" do
435
+ AgUi::A2ui.validate_components(components: [])["valid"].should == false
436
+ AgUi::A2ui.validate_components(components: nil)["valid"].should == false
437
+ end
438
+
439
+ it "flags unknown components and missing required props (catalog only)" do
440
+ mystery = valid_components.().map { |c| c["id"] == "card" ? c.merge("component" => "MysteryCard") : c }
441
+ codes.(AgUi::A2ui.validate_components(components: mystery, data: valid_data, catalog: catalog))
442
+ .should.include?("unknown_component")
443
+
444
+ trimmed = valid_components.().map do |c|
445
+ c["id"] == "card" ? c.reject { |k, _| k == "pricePerNight" } : c
446
+ end
447
+ r = AgUi::A2ui.validate_components(components: trimmed, data: valid_data, catalog: catalog)
448
+ r["errors"].any? { |e| e["code"] == "missing_required_prop" && e["message"].include?("pricePerNight") }
449
+ .should == true
450
+
451
+ # Structural-only without a catalog.
452
+ r = AgUi::A2ui.validate_components(components: mystery, data: valid_data)
453
+ codes.(r).should.not.include?("unknown_component")
454
+ r["valid"].should == true
455
+ end
456
+
457
+ it "flags dangling child references in all three shapes" do
458
+ template = [{ "id" => "root", "component" => "Row",
459
+ "children" => { "componentId" => "ghost", "path" => "/items" } }]
460
+ r = AgUi::A2ui.validate_components(components: template, data: valid_data, catalog: catalog)
461
+ r["errors"].any? { |e| e["code"] == "unresolved_child" && e["message"].include?("ghost") }.should == true
462
+
463
+ array = [{ "id" => "root", "component" => "Row", "children" => ["missing-1"] }]
464
+ codes.(AgUi::A2ui.validate_components(components: array)).should.include?("unresolved_child")
465
+
466
+ singular = [{ "id" => "root", "component" => "Card", "child" => "ghost" }]
467
+ r = AgUi::A2ui.validate_components(components: singular)
468
+ r["errors"].any? { |e| e["code"] == "unresolved_child" && e["path"] == "components[0].child" }
469
+ .should == true
470
+
471
+ resolved = [
472
+ { "id" => "root", "component" => "Card", "child" => "label" },
473
+ { "id" => "label", "component" => "Text" },
474
+ ]
475
+ codes.(AgUi::A2ui.validate_components(components: resolved)).should.not.include?("unresolved_child")
476
+ end
477
+
478
+ it "detects self-referential and multi-component cycles exactly once" do
479
+ selfref = [{ "id" => "avatar", "component" => "Card", "child" => "avatar" }]
480
+ r = AgUi::A2ui.validate_components(components: selfref)
481
+ r["valid"].should == false
482
+ r["errors"].any? { |e| e["code"] == "child_cycle" && e["message"].include?("avatar -> avatar") }
483
+ .should == true
484
+
485
+ loop_comps = [
486
+ { "id" => "root", "component" => "Row", "children" => ["a"] },
487
+ { "id" => "a", "component" => "Row", "children" => ["b"] },
488
+ { "id" => "b", "component" => "Row", "children" => ["a"] },
489
+ ]
490
+ r = AgUi::A2ui.validate_components(components: loop_comps)
491
+ r["errors"].count { |e| e["code"] == "child_cycle" }.should == 1
492
+ r["errors"].any? { |e| e["code"] == "child_cycle" && e["message"].include?("a -> b -> a") }
493
+ .should == true
494
+
495
+ acyclic = [
496
+ { "id" => "root", "component" => "Row", "children" => %w[a b] },
497
+ { "id" => "a", "component" => "Text" },
498
+ { "id" => "b", "component" => "Text" },
499
+ ]
500
+ codes.(AgUi::A2ui.validate_components(components: acyclic)).should.not.include?("child_cycle")
501
+ end
502
+
503
+ it "survives a 5000-deep chain iteratively (no stack overflow)" do
504
+ n = 5000
505
+ comps = [{ "id" => "root", "component" => "Row", "children" => ["n0"] }]
506
+ n.times do |i|
507
+ comps << { "id" => "n#{i}", "component" => "Row",
508
+ "children" => (i + 1 < n ? ["n#{i + 1}"] : []) }
509
+ end
510
+ codes.(AgUi::A2ui.validate_components(components: comps)).should.not.include?("child_cycle")
511
+
512
+ # Same chain closing back to root: exactly one cycle, still no overflow.
513
+ closing = [{ "id" => "root", "component" => "Row", "children" => ["n0"] }]
514
+ n.times do |i|
515
+ closing << { "id" => "n#{i}", "component" => "Row",
516
+ "children" => [i + 1 < n ? "n#{i + 1}" : "root"] }
517
+ end
518
+ AgUi::A2ui.validate_components(components: closing)["errors"]
519
+ .count { |e| e["code"] == "child_cycle" }.should == 1
520
+ end
521
+
522
+ # Catalog-derived ref fields (format: componentRef / componentRefList).
523
+ ref_catalog = {
524
+ "components" => {
525
+ "Modal" => {
526
+ "type" => "object",
527
+ "properties" => {
528
+ "trigger" => { "type" => "string", "format" => "componentRef" },
529
+ "content" => { "type" => "string", "format" => "componentRef" },
530
+ "title" => { "type" => "string" },
531
+ },
532
+ },
533
+ "Tabs" => {
534
+ "type" => "object",
535
+ "properties" => {
536
+ "tabItems" => {
537
+ "type" => "array",
538
+ "items" => { "type" => "object", "properties" => {
539
+ "label" => { "type" => "string" },
540
+ "child" => { "type" => "string", "format" => "componentRef" },
541
+ } },
542
+ },
543
+ },
544
+ },
545
+ "Stack" => { "type" => "object",
546
+ "properties" => { "items" => { "type" => "array", "format" => "componentRefList" } } },
547
+ "Text" => { "type" => "object" },
548
+ },
549
+ }
550
+
551
+ it "derives ref fields from catalog format markers" do
552
+ dangling = [{ "id" => "root", "component" => "Modal",
553
+ "trigger" => "ghost-btn", "content" => "ghost-body", "title" => "Hi" }]
554
+ r = AgUi::A2ui.validate_components(components: dangling, catalog: ref_catalog)
555
+ r["errors"].any? { |e| e["path"] == "components[0].trigger" && e["message"].include?("ghost-btn") }
556
+ .should == true
557
+ r["errors"].any? { |e| e["path"] == "components[0].content" && e["message"].include?("ghost-body") }
558
+ .should == true
559
+
560
+ # Unmarked data strings are never refs.
561
+ ok = [
562
+ { "id" => "root", "component" => "Modal",
563
+ "trigger" => "btn", "content" => "body", "title" => "not-an-id" },
564
+ { "id" => "btn", "component" => "Text" },
565
+ { "id" => "body", "component" => "Text" },
566
+ ]
567
+ codes.(AgUi::A2ui.validate_components(components: ok, catalog: ref_catalog))
568
+ .should.not.include?("unresolved_child")
569
+
570
+ # Without a catalog the marked fields are ignored.
571
+ codes.(AgUi::A2ui.validate_components(components: dangling))
572
+ .should.not.include?("unresolved_child")
573
+ end
574
+
575
+ it "finds nested tabItems[].child and list-ref per-index paths" do
576
+ tabs = [
577
+ { "id" => "root", "component" => "Tabs",
578
+ "tabItems" => [{ "label" => "A", "child" => "panel-a" },
579
+ { "label" => "B", "child" => "ghost-panel" }] },
580
+ { "id" => "panel-a", "component" => "Text" },
581
+ ]
582
+ r = AgUi::A2ui.validate_components(components: tabs, catalog: ref_catalog)
583
+ r["errors"].any? { |e| e["path"] == "components[0].tabItems[1].child" }.should == true
584
+ r["errors"].any? { |e| e["path"] == "components[0].tabItems[0].child" }.should == false
585
+
586
+ stack = [{ "id" => "root", "component" => "Stack", "items" => %w[x ghost-1] },
587
+ { "id" => "x", "component" => "Text" }]
588
+ r = AgUi::A2ui.validate_components(components: stack, catalog: ref_catalog)
589
+ r["errors"].any? { |e| e["path"] == "components[0].items[1]" && e["message"].include?("ghost-1") }
590
+ .should == true
591
+ end
592
+
593
+ it "detects cycles through catalog-marked fields" do
594
+ comps = [
595
+ { "id" => "root", "component" => "Modal", "content" => "b" },
596
+ { "id" => "b", "component" => "Card", "child" => "root" },
597
+ ]
598
+ r = AgUi::A2ui.validate_components(components: comps, catalog: ref_catalog)
599
+ r["errors"].count { |e| e["code"] == "child_cycle" }.should == 1
600
+ end
601
+
602
+ it "resolves absolute bindings against data, leaves relative ones alone" do
603
+ r = AgUi::A2ui.validate_components(components: valid_components.(), data: {}, catalog: catalog)
604
+ r["errors"].any? { |e| e["code"] == "unresolved_binding" && e["message"].include?("/items") }
605
+ .should == true
606
+
607
+ r = AgUi::A2ui.validate_components(components: valid_components.(), data: valid_data, catalog: catalog)
608
+ codes.(r).should.not.include?("unresolved_binding")
609
+
610
+ r = AgUi::A2ui.validate_components(
611
+ components: valid_components.(), data: {}, catalog: catalog, validate_bindings: false,
612
+ )
613
+ codes.(r).should.not.include?("unresolved_binding")
614
+ r["valid"].should == true
615
+ end
616
+ end