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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/data/ag_ui.json +3890 -0
- data/data/generate-ag-ui-schema.py +50 -0
- data/lib/ag_ui/a2ui/catalog.rb +106 -0
- data/lib/ag_ui/a2ui/recovery.rb +216 -0
- data/lib/ag_ui/a2ui/validate.rb +616 -0
- data/lib/ag_ui/event_bridge.rb +174 -0
- data/lib/ag_ui/messages.rb +159 -0
- data/lib/ag_ui/middleware/a2ui.rb +442 -0
- data/lib/ag_ui/middleware/forwarded_props.rb +38 -0
- data/lib/ag_ui/middleware/system_prompt.rb +90 -0
- data/lib/ag_ui/middleware/tool_router.rb +251 -0
- data/lib/ag_ui/protocol/json_schema/definition.rb +233 -0
- data/lib/ag_ui/protocol/json_schema/validation_error.rb +122 -0
- data/lib/ag_ui/protocol/json_schema.rb +270 -0
- data/lib/ag_ui/run_input.rb +149 -0
- data/lib/ag_ui/run_loop.rb +285 -0
- data/lib/ag_ui/run_store.rb +180 -0
- data/lib/ag_ui/server/info.rb +85 -0
- data/lib/ag_ui/server/middleware/sse_stream.rb +160 -0
- data/lib/ag_ui/server/sse/event_encoder.rb +69 -0
- data/lib/ag_ui/server/sse/stream.rb +235 -0
- data/lib/ag_ui/server/triage.rb +208 -0
- data/lib/ag_ui/server.rb +316 -0
- data/lib/ag_ui/terminals/ruby_llm.rb +558 -0
- data/lib/ag_ui/version.rb +5 -0
- data/lib/ag_ui.rb +32 -0
- metadata +242 -0
|
@@ -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
|