ci / vet-and-test (pull_request) Successful in 48s
Why: Generative UI U5 left the B-class action allowlist empty; owner authorized first entries builtin.help and system.intent. Evidence: go test ./internal/genui/ ./internal/surfacesvc/ ./internal/server/ Impact: Surface buttons may bind help/intent; button-entrance intent matches only and does not nest-dispatch write commands. Refs: docs/proposals/2026-10-12-generative-ui-bclass-help-intent.md Co-authored-by: Cursor <cursoragent@cursor.com>
540 lines
19 KiB
Go
540 lines
19 KiB
Go
// Package genui 是 Generative UI 的内置进程内插件(提案 U3):
|
||
// 当前 Agent 经 exposeAsTool 的 generative-ui.present 命令提交声明式页面,
|
||
// 宿主(EXT-17 SurfaceHost)校验并原子发布;目录=提示=校验三同源。
|
||
// 不接管对话循环、模型凭据或任务执行权(KTD2);不另造 Agent runtime。
|
||
package genui
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"agent-system-backend/pkg/extension"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// PluginID 是插件身份(activation 台账与命令前缀共用)。
|
||
const PluginID = "generative-ui"
|
||
|
||
const (
|
||
// CommandPresent 是页面提交命令(revise 即带 surfaceId+baseRevision 的再次 present)。
|
||
CommandPresent = PluginID + ".present"
|
||
// CommandSubmit 是 U5 已批准的 C 类页面动作(表单数据落插件 KV 台账;
|
||
// 不 exposeAsTool——按钮专用,模型不可直接调用)。
|
||
CommandSubmit = PluginID + ".submit"
|
||
// ActionHelp / ActionIntent 是 B 类首批加白(独立评审:builtin.help、system.intent)。
|
||
ActionHelp = "builtin.help"
|
||
ActionIntent = "system.intent"
|
||
)
|
||
|
||
// AllowedActionIDs 是 present 校验与 surfacesvc.AllowedActions 的开放动作集合(须同步)。
|
||
var AllowedActionIDs = map[string]struct{}{
|
||
CommandSubmit: {},
|
||
ActionHelp: {},
|
||
ActionIntent: {},
|
||
}
|
||
|
||
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
|
||
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
|
||
const Catalog = `何时用本工具:用户要求"整理成一页 / 做个对比 / 出一页报告 / 看板 / 时间线"这类**页面化交付**时,优先调用本工具提交声明式页面,而不是写文件工件——页面会在用户预览区直接打开并可继续修订。纯文本问答、简短列表、代码交付保持原方式,不强行出页。
|
||
任务工作页组织(用户在任务/项目工作区查看时):按任务阶段决定页面结构——需求阶段突出待澄清项与决策点(form/kv 优先);执行阶段突出步骤、进度与产物(steps/timeline/table 优先);验收阶段突出验证结果与缺项(kv+markdown 结论优先)。任务完成状态、步骤勾选等**事实字段只能来自对话上下文中的真实任务记录**(计划/待办/验证结果),你不能凭页面渲染改变任务状态——只做事实的组织与呈现。
|
||
|
||
可用节点类型(16 种;字段必须严格匹配,禁止目录外类型/字段):
|
||
section{title?,children?};list{items:string[]};kv{kv:[[k,v]]};
|
||
form{fields:[{key,label,type:text|textarea|number|boolean|select,options?(select必填),required?,placeholder?,help?,default?}]};
|
||
markdown{markdown};doc-ref{docId}(须为会话内存在的工件ID);
|
||
actions{actions:[{id,label,style?:primary|default|danger}]}(不带command);
|
||
chart-lite{chart:{kind:bar|line,labels:[],series:[]等长}};avatar{avatar:{name,sub?,tone?:ok|warn|danger|info,stack?}};
|
||
progress{progress:{label?,value:0~1}};compare{compare:{left:{title?,tone?,nodes?},right:{同left}}};
|
||
tree{tree:{nodes:[{id,label,tone?,badge?,count?,children?}]}};
|
||
table{table:{columns:[{key,label}],rows:[{id?,cells:[[key,值]与columns等长同序}]}};
|
||
steps{steps:{current:0起,steps:[{label,status?:pending|active|done|failed,tone?}]}};
|
||
tabs{tabs:{tabs:[{id,label,nodes?}],active?}};timeline{timeline:{items:[{title,time?,tone?,markdown?,ref?}]}}。
|
||
规则:每节点可带 id(整页唯一稳定)与 source[](支撑事实的来源标识);事实性内容标注来源,
|
||
分析/建议只放 markdown 且不加 source;≤200 节点、≤8 层、≤256KiB;未知类型/字段与未知
|
||
doc-ref 会被拒绝并返回字段级错误(可修正后重提一次)。结构服务目标:比较→compare/table;
|
||
风险→按严重度组织;顺序→steps/timeline;收集输入→form。
|
||
actions 节点纪律:action 的 id 仅允许开放白名单——generative-ui.submit(C 类表单提交)、
|
||
builtin.help(B 类只读帮助)、system.intent(B 类意图匹配;button 入口只返回匹配结果、
|
||
不嵌套分发业务命令)。引用其他 action id 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。
|
||
最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上):
|
||
{"title":"示例页","nodes":[
|
||
{"type":"section","id":"n1","title":"结论","children":[
|
||
{"type":"markdown","id":"n1a","source":["doc:m1"],"markdown":"结论先行。"}]},
|
||
{"type":"table","id":"n2","table":{"columns":[{"key":"k","label":"维度"},{"key":"v","label":"值"}],
|
||
"rows":[{"id":"r1","cells":[["k","报价"],["v","¥8/人/天"]]}]}},
|
||
{"type":"steps","id":"n3","steps":{"current":0,"steps":[{"label":"评审","status":"pending"}]}}]}`
|
||
|
||
func init() {
|
||
extension.RegisterCompiled(extension.InProc{ID: PluginID, Register: register})
|
||
}
|
||
|
||
func register(reg extension.Registry) error {
|
||
spec := json.RawMessage(fmt.Sprintf(`{
|
||
"id": %q,
|
||
"title": "生成页面",
|
||
"description": %q,
|
||
"paramsSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"spec": {"type": "object", "description": "页面规格:顶层恰为 {title: string, nodes: [节点数组]}",
|
||
"properties": {"title": {"type": "string"}, "nodes": {"type": "array", "items": {"type": "object"}}},
|
||
"required": ["title", "nodes"]},
|
||
"surfaceId": {"type": "string", "description": "修订已有页面时传入"},
|
||
"baseRevision": {"type": "integer", "description": "修订基于的当前修订号"},
|
||
"summary": {"type": "string", "description": "一句话语义摘要(回放与索引用)"}
|
||
},
|
||
"required": ["spec"]
|
||
},
|
||
"exposeAsTool": {"risk": "low"}
|
||
}`, CommandPresent, "提交一页声明式页面(由宿主校验、保存并在预览区渲染)。"+Catalog))
|
||
if err := reg.AddCommandHandler(spec, present); err != nil {
|
||
return err
|
||
}
|
||
submitSpec := json.RawMessage(`{
|
||
"id": "generative-ui.submit",
|
||
"title": "提交表单",
|
||
"description": "页面动作:提交用户填写的表单数据(落插件台账;按钮触发,不经模型)",
|
||
"paramsSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"surfaceId": {"type": "string"},
|
||
"revision": {"type": "integer"},
|
||
"requestId": {"type": "string"},
|
||
"data": {"type": "object"}
|
||
},
|
||
"required": ["surfaceId", "requestId", "data"]
|
||
}
|
||
}`)
|
||
return reg.AddCommandHandler(submitSpec, submitForm)
|
||
}
|
||
|
||
// submitForm 是 C 类动作处理器:表单数据落插件自有 KV 台账(键含 requestId
|
||
// 天然幂等;已批准范围限定 KV+任务台账,不建业务事实表)。经命令总线四入口
|
||
// 同源:审计信封与(若未来升档)高危确认卡由总线收口。
|
||
func submitForm(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||
surfaceID, _ := req.Params["surfaceId"].(string)
|
||
requestID, _ := req.Params["requestId"].(string)
|
||
if surfaceID == "" || requestID == "" {
|
||
return nil, fmt.Errorf("generative-ui.submit: surfaceId and requestId required")
|
||
}
|
||
data, _ := json.Marshal(req.Params["data"])
|
||
key := "form:" + surfaceID + ":" + requestID
|
||
if err := h.KVPut(key, string(data)); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := h.Audit("surface.form_submitted", surfaceID, "requestId="+requestID+" bytes="+fmt.Sprint(len(data))); err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]any{"ok": true, "stored": true, "key": key}, nil
|
||
}
|
||
|
||
// ValidateActionIDs 供 present 校验:actions 节点引用的 id 必须在已开放集合内。
|
||
func ValidateActionIDs(spec []byte) []string {
|
||
var s struct {
|
||
Nodes []core.UINode `json:"nodes"`
|
||
}
|
||
if err := json.Unmarshal(spec, &s); err != nil {
|
||
return nil
|
||
}
|
||
var errs []string
|
||
var walk func(n core.UINode, path string)
|
||
walk = func(n core.UINode, path string) {
|
||
if n.Type == core.UIActions {
|
||
for _, a := range n.Actions {
|
||
if _, ok := AllowedActionIDs[a.ID]; !ok {
|
||
errs = append(errs, fmt.Sprintf("%s: 未知动作 id %q(不在开放白名单)", path, a.ID))
|
||
}
|
||
}
|
||
}
|
||
for i, c := range n.Children {
|
||
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
|
||
}
|
||
if n.Compare != nil {
|
||
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
|
||
for i, c := range sd.Nodes {
|
||
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
|
||
}
|
||
}
|
||
}
|
||
if n.Tabs != nil {
|
||
for i, tb := range n.Tabs.Tabs {
|
||
for j, c := range tb.Nodes {
|
||
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for i, n := range s.Nodes {
|
||
walk(n, fmt.Sprintf("$[%d]", i))
|
||
}
|
||
return errs
|
||
}
|
||
|
||
// present 是命令处理器:经 owner 作用域 Host 的 SurfaceHost 能力提交页面。
|
||
// 能力缺失(宿主未装配)与未启用明确报错;doc-ref 经 owner 作用域工件解析做
|
||
// 真实授权(伪造引用拒绝);校验失败返回字段级清单供模型修复重提(至多一次)。
|
||
func present(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||
sh, ok := h.(extension.SurfaceHost)
|
||
if !ok {
|
||
return nil, fmt.Errorf("generative-ui: surface capability not available in this host")
|
||
}
|
||
enabled, epoch, err := sh.SurfaceActivation(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !enabled {
|
||
return nil, fmt.Errorf("generative-ui: 插件未启用(owner 未开启 Surface 能力),本次提交被拒绝")
|
||
}
|
||
raw, _ := json.Marshal(normalizeSpecParam(req.Params["spec"]))
|
||
surfaceID, _ := req.Params["surfaceId"].(string)
|
||
base := 0
|
||
if v, ok := req.Params["baseRevision"].(float64); ok {
|
||
base = int(v)
|
||
}
|
||
summary, _ := req.Params["summary"].(string)
|
||
|
||
// doc-ref 真实授权:spec 里的 docId 必须是当前 owner 可解析的工件(伪造即拒)。
|
||
if errs := authorizeDocRefs(raw, h); len(errs) > 0 {
|
||
return toolError("invalid spec", errs), nil
|
||
}
|
||
// 动作白名单(U5 已批准范围):未知动作 id 拒绝并回字段级错误。
|
||
if errs := ValidateActionIDs(raw); len(errs) > 0 {
|
||
return toolError("invalid spec", errs), nil
|
||
}
|
||
snap, err := sh.SurfaceSave(ctx, extension.SurfaceSaveRequest{
|
||
SurfaceID: core.ID(surfaceID),
|
||
ConversationID: core.ID(req.ConversationID),
|
||
BaseRevision: base,
|
||
ObservedEpoch: epoch,
|
||
SemanticText: summary,
|
||
Spec: raw,
|
||
})
|
||
if err != nil {
|
||
if strings.HasPrefix(err.Error(), "surface: invalid spec:") {
|
||
details := strings.TrimSpace(strings.TrimPrefix(err.Error(), "surface: invalid spec:"))
|
||
var list []string
|
||
for _, d := range strings.Split(details, ";") {
|
||
if t := strings.TrimSpace(d); t != "" {
|
||
list = append(list, t)
|
||
}
|
||
}
|
||
return toolError("invalid spec", list), nil
|
||
}
|
||
return nil, err
|
||
}
|
||
return map[string]any{
|
||
"ok": true,
|
||
"surfaceId": string(snap.Identity.SurfaceID),
|
||
"revision": snap.Revision,
|
||
"epoch": snap.ActivationEpoch,
|
||
"summary": snap.SemanticText,
|
||
"note": "页面已保存;用户在预览区查看。修订时带 surfaceId 与 baseRevision 重提。",
|
||
}, nil
|
||
}
|
||
|
||
// toolError 是返回给模型的字段级错误(工具结果错误,非分发错误)。
|
||
type toolErrorBody struct {
|
||
OK bool `json:"ok"`
|
||
Error string `json:"error"`
|
||
Details []string `json:"details,omitempty"`
|
||
Note string `json:"note"`
|
||
}
|
||
|
||
func toolError(msg string, details []string) toolErrorBody {
|
||
return toolErrorBody{OK: false, Error: msg, Details: details,
|
||
Note: "按错误修正 spec 后可重提一次;再次失败请改用文本回答。"}
|
||
}
|
||
|
||
// normalizeSpecParam 是插件的 schema 修复策略(r1 KTD1;U3 回放实测的模型偏差
|
||
// 逐条落地):单键包装({"page":…})解包;顶层 children→nodes;kv/cells 的
|
||
// 对象形式({"k":…,"v":…}/{"key":…,"value":…})改写为二元组数组。只做结构
|
||
// 改写,不改内容语义;修复后仍过 core 校验器。
|
||
func normalizeSpecParam(v any) any {
|
||
if arr, ok := v.([]any); ok {
|
||
return map[string]any{"title": "", "nodes": arr}
|
||
}
|
||
m, ok := v.(map[string]any)
|
||
if !ok {
|
||
return v
|
||
}
|
||
if len(m) == 1 {
|
||
if inner, ok := m["page"].(map[string]any); ok {
|
||
m = inner
|
||
}
|
||
}
|
||
if _, ok := m["nodes"]; !ok {
|
||
if ch, ok := m["children"].([]any); ok {
|
||
m["nodes"] = ch
|
||
delete(m, "children")
|
||
}
|
||
}
|
||
// 页级 source(模型的常见引用写法):分发给未自带 source 的节点——保留引用
|
||
// 信息,不发明内容;节点自带 source 者不受影响。
|
||
if top, ok := m["source"].([]any); ok {
|
||
delete(m, "source")
|
||
if nodes, ok := m["nodes"].([]any); ok {
|
||
for _, n := range nodes {
|
||
if nm, ok := n.(map[string]any); ok {
|
||
if _, has := nm["source"]; !has {
|
||
cp := make([]any, len(top))
|
||
copy(cp, top)
|
||
nm["source"] = cp
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
repairChildrenShape(m)
|
||
// {"item": …} 单键包装在 children 形状修复之后剥:children 的 {"item":{node}}
|
||
// 由 repairChildrenShape 的对象映射规则兜住,先剥反而会把单子节点打散进
|
||
// 值数组;kv/cells/items/columns 等其余数组字段在这里统一解到本体。
|
||
if unwrapped, ok := repairItemWrappers(m).(map[string]any); ok {
|
||
m = unwrapped
|
||
}
|
||
repairPairs(m)
|
||
repairTableCells(m)
|
||
return m
|
||
}
|
||
|
||
// repairItemWrappers 递归剥掉 XML-RPC 风格的 {"item": X} 单键包装(2026-10-12
|
||
// dev 实测 MiniMax-M3,事件 seq 1395/2171):数组字段(kv/cells/items/columns…)
|
||
// 整字段被包一层 {"item":[…]},数组元素再被包 {"item":["k","v"]}/{"item":"v"}。
|
||
// 规范词汇无名为 item 的字段(core/ui.go 全量核对),单键 item 不承载语义,
|
||
// 安全解包;只改结构不改内容。
|
||
func repairItemWrappers(v any) any {
|
||
switch t := v.(type) {
|
||
case map[string]any:
|
||
if len(t) == 1 {
|
||
if inner, ok := t["item"]; ok {
|
||
return repairItemWrappers(inner)
|
||
}
|
||
}
|
||
out := make(map[string]any, len(t))
|
||
for k, val := range t {
|
||
out[k] = repairItemWrappers(val)
|
||
}
|
||
return out
|
||
case []any:
|
||
out := make([]any, len(t))
|
||
for i, item := range t {
|
||
out[i] = repairItemWrappers(item)
|
||
}
|
||
return out
|
||
default:
|
||
return v
|
||
}
|
||
}
|
||
|
||
// repairTableCells 表格行 cells 的裸标量数组(同场实测):cells 期望与
|
||
// columns.key 对应的 [key,value] 二元组,模型输出成与列等长的裸值数组。
|
||
// 长度一致且列键齐全时按列序配对(结构改写,不发明内容);不一致则不动,
|
||
// 交校验器给字段级错误供模型修复重提。
|
||
func repairTableCells(node any) {
|
||
m, ok := node.(map[string]any)
|
||
if !ok {
|
||
return
|
||
}
|
||
if tbl, ok := m["table"].(map[string]any); ok {
|
||
cols, colsOK := tbl["columns"].([]any)
|
||
rows, rowsOK := tbl["rows"].([]any)
|
||
if colsOK && rowsOK {
|
||
keys := make([]string, len(cols))
|
||
valid := true
|
||
for i, c := range cols {
|
||
cm, ok := c.(map[string]any)
|
||
if !ok {
|
||
valid = false
|
||
break
|
||
}
|
||
k, ok := cm["key"].(string)
|
||
if !ok || k == "" {
|
||
valid = false
|
||
break
|
||
}
|
||
keys[i] = k
|
||
}
|
||
if valid {
|
||
for _, r := range rows {
|
||
rm, ok := r.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
cells, ok := rm["cells"].([]any)
|
||
if !ok || len(cells) != len(keys) {
|
||
continue
|
||
}
|
||
paired := make([]any, 0, len(cells))
|
||
convertible := true
|
||
for i, c := range cells {
|
||
switch cv := c.(type) {
|
||
case string:
|
||
paired = append(paired, []any{keys[i], cv})
|
||
case float64:
|
||
paired = append(paired, []any{keys[i], strconv.FormatFloat(cv, 'f', -1, 64)})
|
||
default:
|
||
convertible = false
|
||
break
|
||
}
|
||
}
|
||
if convertible {
|
||
rm["cells"] = paired
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for _, v := range m {
|
||
if vm, ok := v.(map[string]any); ok {
|
||
repairTableCells(vm)
|
||
}
|
||
if va, ok := v.([]any); ok {
|
||
for _, item := range va {
|
||
if im, ok := item.(map[string]any); ok {
|
||
repairTableCells(im)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// repairChildrenShape 递归修复 children 的常见形状偏差(真实模型实测):
|
||
// children 为对象(按 key 编号的子节点映射)→ 取其值转数组;单对象 → 包一层数组。
|
||
func repairChildrenShape(node any) {
|
||
m, ok := node.(map[string]any)
|
||
if !ok {
|
||
return
|
||
}
|
||
if ch, ok := m["children"]; ok && ch != nil {
|
||
switch v := ch.(type) {
|
||
case map[string]any:
|
||
arr := make([]any, 0, len(v))
|
||
for _, val := range v {
|
||
arr = append(arr, val)
|
||
}
|
||
m["children"] = arr
|
||
case []any:
|
||
for _, c := range v {
|
||
if cm, ok := c.(map[string]any); ok {
|
||
repairChildrenShape(cm)
|
||
}
|
||
}
|
||
default:
|
||
// 单对象子节点
|
||
if cm, ok := ch.(map[string]any); ok {
|
||
m["children"] = []any{cm}
|
||
}
|
||
}
|
||
}
|
||
for _, v := range m {
|
||
if vm, ok := v.(map[string]any); ok {
|
||
repairChildrenShape(vm)
|
||
}
|
||
if va, ok := v.([]any); ok {
|
||
for _, item := range va {
|
||
if im, ok := item.(map[string]any); ok {
|
||
repairChildrenShape(im)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// repairPairs 递归把 "kv"/"cells" 下的对象项改写为二元组。
|
||
func repairPairs(node any) {
|
||
m, ok := node.(map[string]any)
|
||
if !ok {
|
||
return
|
||
}
|
||
for key, val := range m {
|
||
if (key == "kv" || key == "cells") && val != nil {
|
||
if arr, ok := val.([]any); ok {
|
||
fixed := make([]any, 0, len(arr))
|
||
changed := false
|
||
for _, item := range arr {
|
||
if p, ok := pairFromObject(item); ok {
|
||
fixed = append(fixed, p)
|
||
changed = true
|
||
} else {
|
||
fixed = append(fixed, item)
|
||
}
|
||
}
|
||
if changed {
|
||
m[key] = fixed
|
||
}
|
||
}
|
||
}
|
||
repairPairs(val)
|
||
}
|
||
}
|
||
|
||
// pairFromObject 从 {"k","v"}/{"key","value"}/{"name","value"} 对象提取二元组。
|
||
func pairFromObject(item any) ([2]any, bool) {
|
||
obj, ok := item.(map[string]any)
|
||
if !ok {
|
||
return [2]any{}, false
|
||
}
|
||
var k, v any
|
||
for kk, vv := range obj {
|
||
switch kk {
|
||
case "k", "key", "name":
|
||
k = vv
|
||
case "v", "value":
|
||
v = vv
|
||
default:
|
||
return [2]any{}, false
|
||
}
|
||
}
|
||
if k == nil || v == nil {
|
||
return [2]any{}, false
|
||
}
|
||
ks, ok1 := k.(string)
|
||
vs, ok2 := v.(string)
|
||
if !ok1 || !ok2 {
|
||
return [2]any{}, false
|
||
}
|
||
return [2]any{ks, vs}, true
|
||
}
|
||
|
||
// authorizeDocRefs 收集 spec 中的 doc-ref docId 并经 Host 工件面做 owner 作用域解析。
|
||
func authorizeDocRefs(raw []byte, h extension.Host) []string {
|
||
var spec struct {
|
||
Nodes []core.UINode `json:"nodes"`
|
||
}
|
||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||
return []string{"spec: " + err.Error()}
|
||
}
|
||
var errs []string
|
||
var walk func(n core.UINode, path string)
|
||
walk = func(n core.UINode, path string) {
|
||
if n.Type == core.UIDocRef && n.DocID != "" {
|
||
if _, err := h.ArtifactGet(string(n.DocID)); err != nil {
|
||
errs = append(errs, fmt.Sprintf("%s: doc-ref 引用了不存在或无权限的工件 %q", path, n.DocID))
|
||
}
|
||
}
|
||
for i, c := range n.Children {
|
||
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
|
||
}
|
||
if n.Compare != nil {
|
||
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
|
||
for i, c := range sd.Nodes {
|
||
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
|
||
}
|
||
}
|
||
}
|
||
if n.Tabs != nil {
|
||
for i, tb := range n.Tabs.Tabs {
|
||
for j, c := range tb.Nodes {
|
||
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for i, n := range spec.Nodes {
|
||
walk(n, fmt.Sprintf("$[%d]", i))
|
||
}
|
||
return errs
|
||
}
|