Files
dingtalk-workspace-cli/internal/helpers/docjsonml/doc_jsonml_validate_v2.go
539d10f80f feat: 合并 pre-mcp-discovery 分支能力到 main(服务发现保持生产) (#391)
将 test/pre-mcp-discovery 分支上多位同学的能力改动整体合入 main,
服务发现端点保持生产 https://mcp.dingtalk.com,未携带分支上的预发改动。

主要内容:
- 修雨域(chat / report / todo / contact / mail)对齐 wukong 基线 (#355)
- DWS 内容生产能力增强 (#387)、文档导出与 OA 对齐 (#362 #388 #390)
- envelope 注册 cli.Aliases 为 cobra aliases、命令结构与 JSON 解析优化
- skills 多包同步 wukong 对齐内容(attendance/calendar/minutes/oa/sheet 等)
- product/oa 进一步对齐 wukong

排除项:未携带 566b4e1(test: point service discovery to pre-mcp.dingtalk.com);
以下 4 个常量保持生产:skill_command.go / auth/endpoints.go / cli/loader.go / market/registry.go。

Co-authored-by: john <32427341+wqyenjoy@users.noreply.github.com>
Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
2026-06-02 21:04:12 +08:00

310 lines
7.2 KiB
Go

package docjsonml
import (
"fmt"
"strings"
)
// JsonMLValidationResult holds errors and warnings from validation.
type JsonMLValidationResult struct {
Errors []string
Warnings []string
}
// HasErrors returns true if there are blocking errors.
func (r *JsonMLValidationResult) HasErrors() bool {
return len(r.Errors) > 0
}
// Summary returns a human-readable report.
func (r *JsonMLValidationResult) Summary() string {
if !r.HasErrors() && len(r.Warnings) == 0 {
return ""
}
var sb strings.Builder
if len(r.Errors) > 0 {
sb.WriteString(fmt.Sprintf("JSONML 校验失败(%d 个错误):\n", len(r.Errors)))
for i, e := range r.Errors {
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, e))
}
}
if len(r.Warnings) > 0 {
sb.WriteString(fmt.Sprintf("JSONML 校验警告(%d 个):\n", len(r.Warnings)))
for i, w := range r.Warnings {
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, w))
}
}
return sb.String()
}
func (r *JsonMLValidationResult) addError(path, issue, suggestion string) {
r.Errors = append(r.Errors, formatDiag(path, issue, suggestion))
}
func (r *JsonMLValidationResult) addWarn(path, issue, suggestion string) {
r.Warnings = append(r.Warnings, formatDiag(path, issue, suggestion))
}
func formatDiag(path, issue, suggestion string) string {
issue = strings.TrimRight(issue, ".")
if suggestion == "" {
return fmt.Sprintf("%s: %s.", path, issue)
}
return fmt.Sprintf("%s: %s. Suggestion: %s", path, issue, suggestion)
}
// ValidateJsonMLBodyV2 validates a JSONML body using schema-v2.
// Only type mismatches are errors; everything else is a warning.
func ValidateJsonMLBodyV2(body []any) *JsonMLValidationResult {
r := &JsonMLValidationResult{}
if len(body) == 0 {
return r
}
// Root-wrapped: ["root", {attrs}, ...blocks]
if tag, ok := body[0].(string); ok && tag == "root" {
validateNodeV2(body, "$", nil, r)
return r
}
// Single block node
if tag, ok := body[0].(string); ok && schemaV2.IsKnownTag(tag) {
validateNodeV2(body, "$", nil, r)
return r
}
// Array of blocks
for i, node := range body {
nodePath := fmt.Sprintf("$[%d]", i)
if arr, ok := node.([]any); ok && len(arr) > 0 {
if t, ok := arr[0].(string); ok {
nodePath = fmt.Sprintf("$[%d:%s]", i, t)
}
}
validateNodeV2(node, nodePath, nil, r)
}
return r
}
// ValidateJsonMLNodeV2 validates a single JSONML node using schema-v2.
func ValidateJsonMLNodeV2(node any) *JsonMLValidationResult {
r := &JsonMLValidationResult{}
validateNodeV2(node, "$", nil, r)
return r
}
func validateNodeV2(node any, path string, parentSchema *TagSchema, r *JsonMLValidationResult) {
arr, ok := node.([]any)
if !ok {
r.addError(path, fmt.Sprintf("node must be array, got %T", node), "")
return
}
if len(arr) < 1 {
r.addError(path, "node array must not be empty", "")
return
}
tag, ok := arr[0].(string)
if !ok {
r.addError(path, fmt.Sprintf("tag must be string, got %T", arr[0]), "")
return
}
// Check if child is allowed by parent
if parentSchema != nil && !parentSchema.IsAllowedChild(tag) {
r.addWarn(path,
fmt.Sprintf("tag %q not in parent's allowed_children", tag), "")
}
tagSchema := schemaV2.TagSchemaFor(tag)
if tagSchema == nil {
r.addWarn(path, fmt.Sprintf("unknown tag %q", tag), "")
return
}
// Extract attrs
childStart := 1
var attrs map[string]any
if len(arr) > 1 {
if m, ok := arr[1].(map[string]any); ok {
attrs = m
childStart = 2
}
}
// Validate attrs
for key, val := range attrs {
spec, known := tagSchema.Attrs[key]
if !known {
r.addWarn(path+".attrs."+key,
fmt.Sprintf("unknown attr %q", key), "")
continue
}
checkTypeV2(val, &spec, path+".attrs."+key, r)
}
// Validate children
for i := childStart; i < len(arr); i++ {
child := arr[i]
childPath := fmt.Sprintf("%s[%d]", path, i)
switch c := child.(type) {
case string:
if !tagSchema.IsAllowedChild("#text") {
r.addWarn(childPath,
fmt.Sprintf("bare text not allowed in %q", tag), "")
}
_ = c
case []any:
if len(c) > 0 {
if childTag, ok := c[0].(string); ok {
childPath = fmt.Sprintf("%s[%d:%s]", path, i, childTag)
}
}
validateNodeV2(child, childPath, tagSchema, r)
default:
// null, number etc — skip
}
}
}
// checkTypeV2 validates a value against a TypeSpec.
// Type mismatches → error. Enum mismatches → warning.
func checkTypeV2(value any, spec *TypeSpec, path string, r *JsonMLValidationResult) {
switch spec.Type {
case "any":
return
case "string":
if _, ok := value.(string); !ok {
r.addError(path,
fmt.Sprintf("expected string, got %T", value), "")
}
case "number":
num, ok := toFloat64(value)
if !ok {
r.addError(path,
fmt.Sprintf("expected number, got %T", value), "")
return
}
if spec.Min != nil && num < *spec.Min {
r.addError(path,
fmt.Sprintf("value %v < min %v", num, *spec.Min), "")
}
if spec.Max != nil && num > *spec.Max {
r.addError(path,
fmt.Sprintf("value %v > max %v", num, *spec.Max), "")
}
case "boolean":
if _, ok := value.(bool); !ok {
r.addError(path,
fmt.Sprintf("expected boolean, got %T", value), "")
}
case "array":
if _, ok := value.([]any); !ok {
r.addError(path,
fmt.Sprintf("expected array, got %T", value), "")
}
case "object":
obj, ok := value.(map[string]any)
if !ok {
r.addError(path,
fmt.Sprintf("expected object, got %T", value), "")
return
}
// Deep validation if fields defined
if spec.Fields != nil {
for key, val := range obj {
fieldSpec, known := spec.Fields[key]
if !known {
r.addWarn(path+"."+key,
fmt.Sprintf("unknown field %q", key), "")
continue
}
checkTypeV2(val, &fieldSpec, path+"."+key, r)
}
}
case "enum":
str, ok := value.(string)
if !ok {
r.addError(path,
fmt.Sprintf("enum expects string, got %T", value), "")
return
}
found := false
for _, v := range spec.Values {
if v == str {
found = true
break
}
}
if !found {
r.addWarn(path,
fmt.Sprintf("value %q not in enum [%s]", str, strings.Join(spec.Values, ", ")), "")
}
case "union":
if matchesUnion(value, spec.Types) {
return
}
if len(spec.WarnTypes) > 0 && matchesUnion(value, spec.WarnTypes) {
r.addWarn(path,
fmt.Sprintf("value (%T) matches warn_types [%s], expected [%s]", value, strings.Join(spec.WarnTypes, ", "), strings.Join(spec.Types, ", ")), "")
return
}
r.addError(path,
fmt.Sprintf("value (%T) doesn't match any of [%s]", value, strings.Join(spec.Types, ", ")), "")
}
}
func matchesUnion(value any, types []string) bool {
for _, t := range types {
switch t {
case "string":
if _, ok := value.(string); ok {
return true
}
case "number":
if _, ok := toFloat64(value); ok {
return true
}
case "boolean":
if _, ok := value.(bool); ok {
return true
}
case "array":
if _, ok := value.([]any); ok {
return true
}
case "object":
if _, ok := value.(map[string]any); ok {
return true
}
case "null":
if value == nil {
return true
}
case "any":
return true
}
}
return false
}
func toFloat64(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case int:
return float64(n), true
case int64:
return float64(n), true
default:
return 0, false
}
}