唯杰地图 logo唯杰地图
VJMAP
EN

validate-project-json.mjs

vjvp/scripts/validate-project-json.mjs 23.6 KB459 行
原文
/**
 * validate-project-json —— 校验一份手写/生成的工程 JSON 是否"可安全导入"。
 * 纯 Node(只读 JSON,无工作区依赖),可直接用 node 运行。
 *
 * 用法(在 vjvp/ 所在目录执行):
 *   node vjvp/scripts/validate-project-json.mjs <工程.json> [更多.json...]
 *   不带参数时校验技能自带的 examples/*.json。
 *
 * 依据 reference/widget-schema.generated.json(组件类型、字段绑定 option、min/max)做:
 *   组件 type 合法性、必填 position/size、字段绑定三元组形态与 uid 可解析、绑定数量约束、
 *   connections↔connectionData 一致性、rows 键=字段 uid、map-2d 含 map-body、dataRules 引用可解析、
 *   动态数据项 dynamic-prop-entries/dynamic-prop-fields 的 name/键必须是 JSON.stringify(选项路径数组)、
 *   条件样式 conditionalStyles 的 paths/cases 形态与 ruleId 引用可解析。
 * ERROR 会导致退出码 1;WARN 仅提示。
 */
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
const schemaPath = resolve(here, '../references/widget-schema.generated.json')
const examplesDir = resolve(here, '../examples')

const schema = JSON.parse(readFileSync(schemaPath, 'utf8'))

/** type -> Map(bindingOptionName -> {min,max}) ;并收集所有合法 type */
const bindingDefs = new Map()
const widgetTypes = new Set(Object.keys(schema.widgets ?? {}))
const pieceTypes = new Set(Object.keys(schema.pieces ?? {}))

function parseMinMax(typeStr) {
  const min = /min=(\d+)/.exec(typeStr ?? '')
  const max = /max=(\d+)/.exec(typeStr ?? '')
  return { min: min ? Number(min[1]) : undefined, max: max ? Number(max[1]) : undefined }
}
for (const bucket of [schema.widgets, schema.pieces]) {
  for (const [type, def] of Object.entries(bucket ?? {})) {
    const m = new Map()
    for (const opt of def.options ?? []) {
      if (opt.binding) m.set(opt.name, parseMinMax(opt.type))
    }
    bindingDefs.set(type, m)
  }
}

/** type -> Set(全部选项名),用于未知选项名提示 */
const optionNames = new Map()
for (const bucket of [schema.widgets, schema.pieces]) {
  for (const [type, def] of Object.entries(bucket ?? {})) {
    optionNames.set(type, new Set((def.options ?? []).map((o) => o.name).filter(Boolean)))
  }
}
/** type -> Map(选项名 -> 选项定义),用于值类型校验 */
const optionDefs = new Map()
for (const bucket of [schema.widgets, schema.pieces]) {
  for (const [type, def] of Object.entries(bucket ?? {})) {
    optionDefs.set(type, new Map((def.options ?? []).filter((o) => o && o.name).map((o) => [o.name, o])))
  }
}

/**
 * 按 schema 的 type 串校验单个选项值。返回 null(通过)或 { level:'E'|'W', text }。
 * 只校验形态明确的类型:boolean / number / slider / select(有候选) / vector<N> / string / color;field、hidden、window 等交给专门逻辑或放行。
 */
function checkOptionValue(def, v) {
  const t = String(def.type || '')
  const base = t.replace(/[<(].*$/, '')
  const num = (name) => { const m = new RegExp(`${name}=(-?\\d+(?:\\.\\d+)?)`).exec(t); return m ? Number(m[1]) : undefined }
  switch (base) {
    case 'boolean':
      return typeof v === 'boolean' ? null : { level: 'E', text: `应为 true/false,实际是 ${JSON.stringify(v)}` }
    case 'number':
    case 'slider': {
      if (typeof v !== 'number' || Number.isNaN(v)) return { level: 'E', text: `应为数字(${def.alias || ''} ${t}),实际是 ${JSON.stringify(v)}${typeof v === 'boolean' ? '——布尔值会被当成 0/1' : ''}` }
      const min = num('min'), max = num('max')
      if (min !== undefined && v < min) return { level: 'W', text: `${v} 小于下限 ${min}(${t}),运行时会被夹紧` }
      if (max !== undefined && v > max) return { level: 'W', text: `${v} 超过上限 ${max}(${t}),运行时会被夹紧` }
      return null
    }
    case 'select': {
      const choices = Array.isArray(def.selectChoices) ? def.selectChoices.map((c) => (c && typeof c === 'object' ? c.value : c)) : null
      if (choices && choices.length && !choices.includes(v)) return { level: 'E', text: `取值 ${JSON.stringify(v)} 不在候选内:${choices.map((c) => JSON.stringify(c)).join(' / ')}` }
      return null
    }
    case 'vector': {
      const dims = (t.match(/<([^>]+)>/) || [, ''])[1].split(',').filter(Boolean).length
      if (!Array.isArray(v)) return { level: 'E', text: `应为 ${dims || 'N'} 元数组(${t}),实际是 ${JSON.stringify(v)}` }
      if (dims && v.length !== dims) return { level: 'W', text: `应为 ${dims} 元数组(${t}),实际长度 ${v.length}` }
      if (!v.every((x) => typeof x === 'number' || typeof x === 'string')) return { level: 'W', text: `数组元素应为数字(${t})` }
      return null
    }
    case 'string':
      return typeof v === 'string' ? null : { level: 'W', text: `应为字符串,实际是 ${JSON.stringify(v)}` }
    case 'color':
      return typeof v === 'string' ? null : { level: 'E', text: `颜色应为字符串(#hex / rgba() / 渐变串),实际是 ${JSON.stringify(v)}` }
    default:
      return null
  }
}

/** 能放 widgets 子组件的容器类型(页面/窗口节点另行处理;data-repeater 用顶层 unitTemplate、window-instance/scada-2d 用派生子级,均不算。basic-carousel 不 initContainer、不渲染子组件,故不在列) */
const CONTAINER_TYPES = new Set([
  'group-panel', 'group-tabs', 'group-popup',
  'layout-row', 'layout-column', 'layout-grid', 'layout-flow-row', 'layout-fixed-panel', 'layout-dock', 'layout-sidebar', 'layout-accordion', 'layout-section',
])
/** 写在 options 里但不属于组件选项字典的通用键(交互 / 多状态 / 条件样式等运行时另行读取) */
const NODE_LEVEL_OPTION_KEYS = new Set(['interactions', 'states', 'conditionalStyles', 'dynamic-prop-entries', 'dynamic-prop-fields', 'prop-source-entries', 'prop-source-fields'])

function nearest(name, candidates, max = 3) {
  const scored = []
  for (const c of candidates) {
    const d = levenshtein(name, c)
    if (d <= Math.max(2, Math.floor(name.length / 3))) scored.push([d, c])
  }
  return scored.sort((a, b) => a[0] - b[0]).slice(0, max).map(([, c]) => c)
}
function levenshtein(a, b) {
  const m = a.length, n = b.length
  if (!m) return n
  if (!n) return m
  let prev = Array.from({ length: n + 1 }, (_, j) => j)
  for (let i = 1; i <= m; i++) {
    const cur = [i]
    for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
    prev = cur
  }
  return prev[n]
}

function looksLikeTriple(v) {
  return Array.isArray(v) && v.length === 3 && v.every((x) => typeof x === 'string')
}
function asTriples(v) {
  // 接受 [[c,t,f],...] 或单个 [c,t,f]
  if (looksLikeTriple(v)) return [v]
  if (Array.isArray(v) && v.every(looksLikeTriple)) return v
  return null
}
/**
 * 严格形态:仅认「三元组数组」[[c,t,f],...](至少一条,且每条都是 3 个字符串)。
 * 用于「未知选项名」的启发式绑定探测——避免把 operate-text-list 等恰好 3 个字符串的
 * 普通字符串列表误判成字段三元组。字段绑定在本工程一律以嵌套数组存储,故此处足够。
 */
function asTriplesStrict(v) {
  if (Array.isArray(v) && v.length > 0 && v.every(looksLikeTriple)) return v
  return null
}

function validate(body, label) {
  const errors = []
  const warns = []
  const E = (m) => errors.push(m)
  const W = (m) => warns.push(m)

  // —— 数据索引 ——
  const connections = body.connections ?? []
  const connectionData = body.connectionData ?? {}
  const connByUid = new Map()
  for (const c of connections) connByUid.set(c.uid, c)
  const dcUids = new Set((body.dataRules ?? []).map((d) => d.uid))

  function fieldResolvable(conn, table, field) {
    const c = connByUid.get(conn)
    let ok = false
    if (c) {
      const t = (c.tables ?? []).find((t) => t.uid === table)
      if (t && (t.fields ?? []).some((f) => f.uid === field)) ok = true
    }
    const dts = connectionData[conn] ?? []
    const dt = dts.find((t) => t.tableId === table)
    if (dt && (dt.fields ?? []).some((f) => f.uid === field)) ok = true
    return ok
  }

  // —— project / screen ——
  const project = body.project
  if (!project) E('缺少 project')
  const screen = project?.screen
  if (!screen) E('缺少 project.screen(主页面必填)')
  if (screen && screen.type !== 'screen') E(`project.screen.type 应为 "screen",实际 "${screen.type}"`)
  if (screen && !Array.isArray(screen.widgets)) E('project.screen.widgets 应为数组')

  const seenUids = new Set()

  function walkNode(node, ctx, parentFluid = false, registerUid = true, uidScope = seenUids) {
    if (!node || typeof node !== 'object') return
    const { type, uid, options = {} } = node
    if (!uid) E(`${ctx}: 节点缺少 uid (type=${type})`)
    else if (registerUid) {
      // 组合组件母版子树用「模板 uid」,渲染时派生为 实例uid::母版子uid,故不计入全局 uid 唯一性(registerUid=false)
      if (uidScope.has(uid)) E(`${ctx}: uid 重复 "${uid}"`)
      else uidScope.add(uid)
    }

    const isPiece = pieceTypes.has(type)
    const isWidget = widgetTypes.has(type)
    if (!isPiece && !isWidget) {
      E(`${ctx}: 未知 type "${type}"(应是已注册组件/图层)`)
    }

    // 组件(非 piece、非容器子级根)建议有 position/size。
    // 例外:父容器为「流式(fluid)」时,子项定位/尺寸由 flex 流 + fluid-*-* 选项决定,position/size 不参与布局,故不强求。
    if (isWidget && !parentFluid) {
      if (!Array.isArray(options.position)) W(`${ctx} (${type} ${uid}): 建议显式 options.position,否则叠在 [0,0]`)
      if (!Array.isArray(options.size)) W(`${ctx} (${type} ${uid}): 建议显式 options.size`)
    }

    // 只有容器类组件能放 widgets;把图表塞进 basic-graphical 等非容器里不会渲染(只剩三块纯色面板)
    if (isWidget && Array.isArray(node.widgets) && node.widgets.length > 0 && !CONTAINER_TYPES.has(type)) {
      E(`${ctx} (${type} ${uid}): 非容器组件不能嵌套 ${node.widgets.length} 个子组件(不会渲染)。面板请改用 group-panel(background / border),或把 basic-graphical 作为同层背景放在前面、子组件放在后面`)
    }

    // 未知选项名提示(TSX 产出链靠 compile.mts 拦臆造的 kebab 名;直接写 JSON 时在这里兜底,只给 warn 并列出相近候选)
    const knownNames = optionNames.get(type)
    if (knownNames) {
      for (const k of Object.keys(options)) {
        if (knownNames.has(k) || NODE_LEVEL_OPTION_KEYS.has(k)) continue
        const near = nearest(k, knownNames)
        W(`${ctx} (${type}): 未知选项名 "${k}"${near.length ? `(相近:${near.join(' / ')})` : ''}——运行时会被忽略,请对照 widget-schema.generated.json`)
      }
    }

    // 选项值类型 / 取值范围 / select 候选(如 row-num 写成 true 被当成 1 行、bar-height 超上限被夹紧)
    const defs = optionDefs.get(type)
    if (defs) {
      for (const [k, v] of Object.entries(options)) {
        const def = defs.get(k)
        if (!def || v === null || v === undefined) continue
        const msg = checkOptionValue(def, v)
        if (msg) (msg.level === 'E' ? E : W)(`${ctx} (${type} ${uid}): 选项 "${k}" ${msg.text}`)
      }
    }

    // 字段绑定校验:已知绑定项 + 任何"看起来像三元组"的值
    const knownBind = bindingDefs.get(type) ?? new Map()
    for (const [k, v] of Object.entries(options)) {
      const isKnownBind = knownBind.has(k)
      // 已知绑定项:接受裸三元组或嵌套;未知项:仅当为「三元组数组」[[c,t,f],...] 才视作绑定,
      // 以免把 operate-text-list 等「恰好 3 个字符串」的普通字符串列表误判为字段三元组。
      const triples = isKnownBind ? asTriples(v) : asTriplesStrict(v)
      if (!triples && isKnownBind && v != null && (Array.isArray(v) ? v.length : true)) {
        // 绑定项存在但形态不对(空数组允许=未绑定)
        if (!(Array.isArray(v) && v.length === 0)) E(`${ctx} (${type}): 绑定项 "${k}" 形态应为 [[connUid,tableUid,fieldUid],...]`)
        continue
      }
      if (!triples) continue
      for (const tr of triples) {
        const [conn, table, field] = tr
        if (conn === '__vp_private__') continue // 私有数据
        if (!fieldResolvable(conn, table, field))
          E(`${ctx} (${type}): 绑定 "${k}" 的三元组 [${conn},${table},${field}] 无法在 connections/connectionData 中解析`)
      }
      if (isKnownBind) {
        const { min, max } = knownBind.get(k)
        if (min != null && triples.length < min) E(`${ctx} (${type}): "${k}" 至少需 ${min} 个字段,实际 ${triples.length}`)
        if (max != null && triples.length > max) E(`${ctx} (${type}): "${k}" 最多 ${max} 个字段,实际 ${triples.length}`)
      }
    }

    // 动态数据项:dynamic-prop-entries[].name 与 dynamic-prop-fields 的键都必须是 JSON.stringify(选项路径数组)
    // 运行时 watchRelatedFields 会 JSON.parse(entry.name) 得到目标路径;写成裸 "opacity" 会抛 JSON 解析错误。
    const relEntries = options['dynamic-prop-entries']
    const relFields = options['dynamic-prop-fields']
    const entryNames = new Set()
    const isJsonPathArray = (s) => {
      if (typeof s !== 'string') return false
      try {
        const p = JSON.parse(s)
        return Array.isArray(p) && p.length > 0 && p.every((x) => typeof x === 'string')
      } catch {
        return false
      }
    }
    if (relEntries !== undefined) {
      if (!Array.isArray(relEntries)) E(`${ctx} (${type}): dynamic-prop-entries 应为数组`)
      else
        for (const [i, e] of relEntries.entries()) {
          const nm = e && typeof e === 'object' ? e.name : undefined
          if (typeof nm !== 'string') {
            E(`${ctx} (${type}): dynamic-prop-entries[${i}] 缺少字符串 name`)
            continue
          }
          entryNames.add(nm)
          if (!isJsonPathArray(nm))
            E(`${ctx} (${type}): dynamic-prop-entries[${i}].name 必须是「选项路径数组」的 JSON 串,如 "[\\"opacity\\"]"(当前 ${JSON.stringify(nm)})`)
        }
    }
    if (relFields !== undefined) {
      if (typeof relFields !== 'object' || Array.isArray(relFields)) E(`${ctx} (${type}): dynamic-prop-fields 应为对象(键=dynamic-prop-entries 的 name)`)
      else
        for (const [key, val] of Object.entries(relFields)) {
          if (!isJsonPathArray(key))
            E(`${ctx} (${type}): dynamic-prop-fields 键必须是「选项路径数组」的 JSON 串,如 "[\\"opacity\\"]"(当前 ${JSON.stringify(key)})`)
          if (relEntries !== undefined && !entryNames.has(key))
            E(`${ctx} (${type}): dynamic-prop-fields 键 ${JSON.stringify(key)} 在 dynamic-prop-entries 中无对应 name(不会被监听)`)
          const tr = asTriples(val && typeof val === 'object' ? val['dynamic-prop-field'] : undefined)
          if (!tr) E(`${ctx} (${type}): dynamic-prop-fields[${JSON.stringify(key)}].dynamic-prop-field 形态应为 [[connUid,tableUid,fieldUid]]`)
          else
            for (const t of tr) {
              const [c, tb, f] = t
              if (c !== '__vp_private__' && !fieldResolvable(c, tb, f))
                E(`${ctx} (${type}): dynamic-prop-field 三元组 [${c},${tb},${f}] 无法解析`)
            }
        }
    }
    for (const nm of entryNames) if (!(relFields && nm in relFields)) W(`${ctx} (${type}): dynamic-prop-entries 项 ${JSON.stringify(nm)} 未在 dynamic-prop-fields 绑定字段`)

    // 条件样式 conditionalStyles(node 级;paths 直接用作选项路径,不做 JSON 编码)
    if (node.conditionalStyles !== undefined) {
      if (!Array.isArray(node.conditionalStyles)) E(`${ctx} (${type}): conditionalStyles 应为数组`)
      else
        for (const [i, cs] of node.conditionalStyles.entries()) {
          if (!cs || !Array.isArray(cs.paths) || !cs.paths.length || !cs.paths.every((s) => typeof s === 'string'))
            E(`${ctx} (${type}): conditionalStyles[${i}].paths 应为非空字符串数组(选项路径)`)
          for (const [j, e] of (cs?.cases ?? []).entries()) {
            if (!e || typeof e !== 'object') {
              E(`${ctx} (${type}): conditionalStyles[${i}].cases[${j}] 应为对象`)
              continue
            }
            if (!('value' in e)) E(`${ctx} (${type}): conditionalStyles[${i}].cases[${j}] 缺少 value`)
            if (!e.fallback && !e.inlineRules && e.ruleId == null)
              E(`${ctx} (${type}): conditionalStyles[${i}].cases[${j}] 需 ruleId / inlineRules / fallback 之一`)
            if (e.ruleId != null && !dcUids.has(e.ruleId))
              W(`${ctx} (${type}): conditionalStyles[${i}] 引用的数据规则 ${JSON.stringify(e.ruleId)} 不在 dataRules 中`)
          }
        }
    }

    // map-2d 必含 map-body
    if (type === 'map-2d') {
      const pieces = node.pieces ?? []
      if (!pieces.length) E(`${ctx}: map-2d 必须有 pieces`)
      else if (!pieces.some((p) => p.type === 'map-body')) E(`${ctx}: map-2d 的 pieces 必须含一个 map-body`)
    }

    // component-instance:实例引用母版(masterId);自带母版快照 master 用于离线/导出渲染。
    // 校验 masterId/props/overrides 形态,并递归校验内嵌母版子树(uid 为模板 uid,渲染派生为 实例::子,故 registerUid=false)。
    if (type === 'component-instance') {
      if (typeof node.masterId !== 'string' || !node.masterId) E(`${ctx}: component-instance 缺少 masterId(字符串)`)
      if (node.props !== undefined && (typeof node.props !== 'object' || Array.isArray(node.props))) E(`${ctx}: component-instance.props 应为对象`)
      if (node.overrides !== undefined && (typeof node.overrides !== 'object' || Array.isArray(node.overrides))) E(`${ctx}: component-instance.overrides 应为对象`)
      const master = node.master
      if (master && typeof master === 'object') {
        if (!master.node || master.node.type !== 'group-panel') E(`${ctx}: component-instance.master.node 应为 group-panel 子树`)
        else walkNode(master.node, `${ctx} > master`, false, false)
        if (master.propsSchema !== undefined && !Array.isArray(master.propsSchema)) E(`${ctx}: master.propsSchema 应为数组`)
      } else {
        W(`${ctx}: component-instance 无内嵌 master 快照(运行时需从组件库按 masterId 解析)`)
      }
    }

    // 容器自身是否流式:决定其子项是否需要 position/size
    const selfFluid = options['container-layout'] === 'fluid'
    for (const child of node.widgets ?? []) walkNode(child, `${ctx} > ${type}.widgets`, selfFluid, registerUid, uidScope)
    // pieces 的 uid 命名空间是「所属 builder 组件内部」(triggerTarget/数据源引用都在组件内解析):
    // 每个组件各开一个作用域,多个地图/组态/三维组件可各自使用固定 uid(map-body / scene3d-scene / scene3d-camera)。
    const pieceScope = new Set()
    for (const child of node.pieces ?? []) walkNode(child, `${ctx} > ${type}.pieces`, selfFluid, registerUid, pieceScope)
    // data-repeater 的单元模板(持久化于 unitTemplate,非 widgets):递归校验模板及其子组件绑定
    if (node.unitTemplate) {
      if (node.unitTemplate.type !== 'group-panel') E(`${ctx} (${type}): unitTemplate.type 应为 "group-panel",实际 "${node.unitTemplate.type}"`)
      walkNode(node.unitTemplate, `${ctx} > ${type}.unitTemplate`, false, registerUid, uidScope)
    }
  }

  const screenFluid = screen?.options?.['container-layout'] === 'fluid'
  for (const w of screen?.widgets ?? []) walkNode(w, 'screen.widgets', screenFluid)
  for (const extra of project?.screens ?? []) {
    const extraFluid = extra?.options?.['container-layout'] === 'fluid'
    for (const w of extra.widgets ?? []) walkNode(w, 'screens[].widgets', extraFluid)
  }

  // —— connections ↔ connectionData ——
  // 精简交换格式:connectionData[].tableName/fields 可缺省(由 connections 对应表推导);
  // 若显式写了 fields,允许与 connections 不同(有意覆盖,如改过 revisedType),仅当字段集合不同时给 warn。
  for (const c of connections) {
    const dts = connectionData[c.uid]
    for (const t of c.tables ?? []) {
      const dt = (dts ?? []).find((d) => d.tableId === t.uid)
      if (!dt) {
        if (['json', 'csv', 'excel', 'static'].includes(c.connectorType ?? c.type))
          W(`连接 ${c.uid} 表 ${t.uid}: 静态/文件类缺少 connectionData 行数据(组件将空数据)`)
        continue
      }
      const connFieldIds = (t.fields ?? []).map((f) => f.uid)
      const hasOwnFields = Array.isArray(dt.fields)
      // 工程经 REST API 原样入库(不经过编辑器导入的补齐),运行时按 connectionData.fields 取字段元数据,
      // 缺失会让绑定该表的图表整块空白(TypeError: reading 'find')。vp-cli import 会自动补齐,这里提醒。
      if (!hasOwnFields || !dt.fields.length) W(`连接 ${c.uid} 表 ${t.uid}: connectionData 缺少 fields(精简形态)——直接 API 导入会导致图表空白;请补全 fields 后导入`)
      // 有效字段:显式 connectionData.fields 优先,否则继承 connections(精简形态)
      const effIds = hasOwnFields ? dt.fields.map((f) => f.uid) : connFieldIds
      if (hasOwnFields) {
        const a = new Set(connFieldIds)
        const b = new Set(effIds)
        const differ = a.size !== b.size || [...a].some((f) => !b.has(f))
        if (differ) W(`连接 ${c.uid} 表 ${t.uid}: connectionData.fields 字段集合与 connections 不同(按需重复定义,请确认是有意覆盖)`)
      }
      for (const [i, row] of (dt.rows ?? []).entries()) {
        for (const f of effIds) if (!(f in row)) W(`连接 ${c.uid} 表 ${t.uid} 第 ${i} 行: 缺少字段键 "${f}"`)
      }
    }
  }

  // —— dataRules 引用 ——
  for (const dc of body.dataRules ?? []) {
    for (const group of dc.rules ?? []) {
      for (const r of group ?? []) {
        if (!r.field) continue
        if (['row', 'reverseRow', 'count'].includes(r.field)) continue
        if (r.connection === '__vp_project_params__') continue
        if (r.connection && r.table && !fieldResolvable(r.connection, r.table, r.field))
          W(`dataRule ${dc.uid}: 规则引用 [${r.connection},${r.table},${r.field}] 无法解析`)
      }
    }
  }

  return { errors, warns }
}

export { validate }

function isMain() {
  const self = fileURLToPath(import.meta.url)
  const invoked = process.argv[1] ? resolve(process.argv[1]) : ''
  return self === invoked
}

function main() {
  const args = process.argv.slice(2)
  const files = args.length
    ? args.map((a) => resolve(process.cwd(), a))
    : readdirSync(examplesDir)
        .filter((f) => f.endsWith('.json'))
        .map((f) => resolve(examplesDir, f))

  let totalErrors = 0
  for (const file of files) {
    let body
    try {
      body = JSON.parse(readFileSync(file, 'utf8'))
    } catch (e) {
      console.log(`\n✗ ${file}\n  JSON 解析失败: ${e.message}`)
      totalErrors++
      continue
    }
    const { errors, warns } = validate(body, file)
    const tag = errors.length ? '✗' : '✓'
    console.log(`\n${tag} ${file}`)
    for (const e of errors) console.log(`  ERROR: ${e}`)
    for (const w of warns) console.log(`  warn : ${w}`)
    if (!errors.length && !warns.length) console.log('  OK')
    totalErrors += errors.length
  }
  console.log(`\n${totalErrors ? '存在 ' + totalErrors + ' 个 ERROR' : '全部通过(无 ERROR)'}`)
  process.exit(totalErrors ? 1 : 0)
}

if (isMain()) main()