cardbuddy 0.9.1 → 0.11.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.
Files changed (64) hide show
  1. package/README.md +21 -6
  2. package/dist/cli.js +17 -6
  3. package/dist/config.js +20 -2
  4. package/dist/portGuard.js +293 -0
  5. package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
  6. package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
  7. package/dist/preview-editor/index.html +2 -2
  8. package/dist/server-core/_vendor/finmall-env.mjs +4 -4
  9. package/dist/server-core/ai-chat.js +3710 -0
  10. package/dist/server-core/assetProxy.js +3 -22
  11. package/dist/server-core/buildDataFromSchema.js +36 -4
  12. package/dist/server-core/cardFiles.js +95 -2
  13. package/dist/server-core/cardImage.js +14 -0
  14. package/dist/server-core/cardListCache.js +0 -17
  15. package/dist/server-core/cardSave.js +2 -0
  16. package/dist/server-core/cardValidate.js +3 -0
  17. package/dist/server-core/index.js +6 -5
  18. package/dist/server-core/logStream.js +1 -6
  19. package/dist/server-core/sliceUpload.js +8 -1
  20. package/dist/standalone.js +24 -8
  21. package/dist/vendored/App.vue +405 -125
  22. package/dist/vendored/api.js +4 -4
  23. package/dist/vendored/assets/svgIcons/icons.svg +28 -0
  24. package/dist/vendored/components/AiSettings.vue +392 -0
  25. package/dist/vendored/components/CardGallery.vue +51 -5
  26. package/dist/vendored/components/ChatComposer.vue +413 -0
  27. package/dist/vendored/components/ChatMessage.vue +383 -0
  28. package/dist/vendored/components/ChatPanel.vue +937 -0
  29. package/dist/vendored/components/ComponentPalette.vue +1 -1
  30. package/dist/vendored/components/DebugPanel.vue +732 -90
  31. package/dist/vendored/components/Dropdown.vue +4 -2
  32. package/dist/vendored/components/MarkdownBody.vue +64 -0
  33. package/dist/vendored/components/NewCardDialog.vue +132 -0
  34. package/dist/vendored/components/NumberStepper.vue +125 -0
  35. package/dist/vendored/components/ProgressSlider.vue +9 -1
  36. package/dist/vendored/components/RemotePreview.vue +1 -1
  37. package/dist/vendored/components/StructureTree.vue +2 -2
  38. package/dist/vendored/main.js +1 -0
  39. package/dist/vendored/runtime/adapter.js +1 -1
  40. package/dist/vendored/runtime/ai-api.js +165 -0
  41. package/dist/vendored/runtime/clipboard-node.js +0 -5
  42. package/dist/vendored/runtime/markdown.js +140 -0
  43. package/dist/vendored/skills/.manifest.json +1101 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
  46. package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
  49. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
  50. package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
  51. package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
  52. package/dist/vendored/styles/ai-chat.css +1850 -0
  53. package/dist/vendored/styles/app.css +725 -213
  54. package/dist/vendored/styles/card-gallery.css +48 -10
  55. package/dist/vendored/styles/card-select.css +13 -6
  56. package/dist/vendored/styles/dropdown.css +1 -2
  57. package/dist/vendored/styles/json-tree.css +3 -3
  58. package/dist/vendored/styles/node-inspector.css +13 -289
  59. package/dist/vendored/styles/setters.css +48 -49
  60. package/dist/vendored/styles/structure-tree.css +6 -11
  61. package/package.json +1 -1
  62. package/dist/preview-editor/assets/index-B9MOriNv.js +0 -80
  63. package/dist/preview-editor/assets/index-CwD14gm3.css +0 -1
  64. package/dist/vendored/assets/logo.png +0 -0
@@ -0,0 +1,624 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ DSL Schema Validator (hardened)
4
+
5
+ Validates a generated schema.json for structural correctness and dsl-card
6
+ contract conformance:
7
+
8
+ - top-level structure, component tree, unique componentId
9
+ - known className / property / style keys
10
+ - itemValueFrom / itemDefaultValue rules
11
+ - v-show shape (itemValue:{} + itemChildren.properties)
12
+ - data.json consistency with dictionary.inputParams.defaultValue
13
+
14
+ When run from inside the repo it loads `vendor/preview/meta/{style,component}-meta.json`
15
+ to validate style itemId groups and the allowed component set dynamically;
16
+ otherwise it falls back to built-in tables.
17
+
18
+ Usage:
19
+ python3 validate_schema.py <schema.json> # validate one card
20
+ python3 validate_schema.py <schema.json> --no-data # skip data.json check
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Built-in fallbacks (used only if vendor/preview/meta is unavailable)
31
+ # ---------------------------------------------------------------------------
32
+ COMPONENT_TYPES = {
33
+ "Root", "Row", "Column", "Canvas", "AbsoluteLayout", "MultiColumn",
34
+ "Text", "Button", "Input", "Icon", "Divider", "Image", "Select", "Option",
35
+ "ListLayout", "GridLayout", "MultiText", "ViewContainer",
36
+ "SingleColumn", "CheckGroup", "RadioButton", "ProgressSlider", "Video", "Map"
37
+ }
38
+
39
+ CONTAINER_TYPES = {
40
+ "Root", "Row", "Column", "AbsoluteLayout", "MultiColumn",
41
+ "ListLayout", "GridLayout", "SingleColumn", "CheckGroup", "RadioButton"
42
+ }
43
+
44
+ STYLE_KEYS = {"rect", "font", "background", "flex", "padding", "margin", "image",
45
+ "border", "borderRadius", "opacity", "display", "other", "shadow", "transform",
46
+ "position"}
47
+
48
+ STYLE_SUB_KEYS = {"width", "height", "color", "fontSize", "fontWeight", "textAlign", "lineHeight",
49
+ "fontStyle", "textDecoration", "isMaxLine", "maxLine",
50
+ "backgroundType", "backgroundColor", "backgroundImage", "backgroundGradient",
51
+ "gap", "align", "alignItems",
52
+ "justifyContent", "flexDirection", "flexWrap", "flexShrink",
53
+ "paddingTop", "paddingBottom", "paddingLeft", "paddingRight",
54
+ "paddingType", "padding",
55
+ "marginTop", "marginBottom", "marginLeft", "marginRight", "marginType", "margin",
56
+ "borderRadiusType", "borderRadius",
57
+ "borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius",
58
+ "isBorder", "borderWidth", "borderColor", "borderStyle", "backgroundImage", "cropMode"}
59
+ STYLE_SUB_KEYS.update({"opacity", "overflow", "isShadow", "borderRadiusType", "backgroundPosition",
60
+ "backgroundSize", "backgroundRepeat", "rotate", "shadowColor", "shadowX",
61
+ "shadowY", "shadowBlur", "shadowSpread", "left", "top", "right", "bottom"})
62
+
63
+ PROP_KEYS = {"value", "placeholder", "disabled", "icon", "color", "content", "empty", "itemDefaultValue",
64
+ "direction", "row-spacing", "edge-insets", "inputType", "maxLength", "align",
65
+ "column", "item-spacing", "textList", "customClass", "customProps", "customRemarkImg",
66
+ "message"}
67
+
68
+ DIRECTIVE_KEYS = {"v-show"}
69
+ EVENT_KEYS = {"onReady", "click", "onMeasured", "action"}
70
+ ACTION_TYPES = {"customAction", "startRouter", "closeAlertCard", "sendAiChatMessage"}
71
+ # 运行时 property.js onPreview 严格按字符串比较 itemValueFrom === '0'/'1'/'2';
72
+ # 缺失或空串会导致该属性在预览时被**静默丢弃**(背景/内边距/gap/对齐全部失效)。
73
+ # 因此只接受字符串 "0"/"1"/"2";缺失/空串/数字由 check_value_from 升级为 ERROR。
74
+ ITEM_VALUE_FROM_VALUES = {"0", "1", "2"}
75
+
76
+ # augment 标志:避免重复扫 meta
77
+ _META_KEYS_AUGMENTED = False
78
+
79
+ # style-meta 派生:groupKey -> {childKey: childItemId},用于校验样式子项 itemId 正确性
80
+ # (捕捉 flexDirection 误用 002 而非 006 这类「itemId 存在但放错组」的隐形 bug)。
81
+ _STYLE_MAPS_LOADED = False
82
+ STYLE_GROUP_CHILD_MAP = {}
83
+
84
+ FLEX_ENUMS = {
85
+ "justifyContent": {"flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly"},
86
+ "alignItems": {"flex-start", "flex-end", "center", "stretch", "baseline"},
87
+ "flexDirection": {"row", "row-reverse", "column", "column-reverse"},
88
+ "flexWrap": {"nowrap", "wrap", "wrap-reverse"},
89
+ }
90
+
91
+ # data.json keys that are allowed to differ from inputParams.defaultValue (i18n drift)
92
+ DATA_JSON_TOLERATED_KEYS = {"otherLangLabels"}
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Dynamic meta loading
96
+ # ---------------------------------------------------------------------------
97
+ def _meta_dir():
98
+ """skill 自包含 meta 目录。
99
+
100
+ 不再依赖仓库根或 package.json 锚点;直接委托给 scripts/lib/paths.py 的 meta_dir。
101
+ """
102
+ try:
103
+ # 同目录下的 lib/paths.py;本文件位于 scripts/validate_schema.py
104
+ sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
105
+ from paths import meta_dir
106
+ return meta_dir
107
+ except Exception:
108
+ # 极端兜底(如本文件被单独拷出去执行):使用内置 fallback,跳过动态 meta
109
+ return Path(__file__).resolve().parent / ".." / "vendor" / "preview" / "meta"
110
+
111
+ def _augment_keys_from_meta(meta_dir):
112
+ """
113
+ 从 component-meta.json / action-meta.json 把 PROP_KEYS / EVENT_KEYS / ACTION_TYPES
114
+ 动态 augment 进模块级常量(保留内置值作 fallback)。
115
+
116
+ 之前:内置白名单只覆盖老组件,新组件(CheckGroup/RadioButton/ProgressSlider/
117
+ Video/Map/ViewContainer 等)的属性/事件持续报 `Unknown property/event key` 警告
118
+ (docs/节点编辑问题清单.md #2)。现在从 meta 里把所有 properties.itemKey(含
119
+ itemChildren 递归)、events.eventKey、actions.actionType、lifecycleEvents.eventKey
120
+ 都并入白名单,消除噪音。
121
+ """
122
+ global PROP_KEYS, EVENT_KEYS, ACTION_TYPES
123
+
124
+ cm = meta_dir / "component-meta.json"
125
+ if cm.is_file():
126
+ try:
127
+ data = json.load(open(cm, encoding="utf-8"))
128
+ comps = data.get("components")
129
+ if isinstance(comps, dict):
130
+ def walk_item(item):
131
+ """递归收集一个属性项及其 itemChildren 的所有 itemKey。"""
132
+ if not isinstance(item, dict):
133
+ return
134
+ ik = item.get("itemKey")
135
+ if ik:
136
+ PROP_KEYS.add(ik)
137
+ for c in item.get("itemChildren") or []:
138
+ walk_item(c)
139
+ # MultiText 等用 itemList(每段一个属性组)
140
+ for entry in item.get("itemList") or []:
141
+ for c in entry.get("properties") or []:
142
+ walk_item(c)
143
+
144
+ for cmeta in comps.values():
145
+ for p in cmeta.get("properties") or []:
146
+ walk_item(p)
147
+ for e in cmeta.get("events") or []:
148
+ ek = e.get("eventKey")
149
+ if ek:
150
+ EVENT_KEYS.add(ek)
151
+ except Exception:
152
+ pass
153
+
154
+ am = meta_dir / "action-meta.json"
155
+ if am.is_file():
156
+ try:
157
+ data = json.load(open(am, encoding="utf-8"))
158
+ for a in data.get("actions") or []:
159
+ at = a.get("actionType")
160
+ if at:
161
+ ACTION_TYPES.add(at)
162
+ for ev in data.get("lifecycleEvents") or []:
163
+ ek = ev.get("eventKey")
164
+ if ek:
165
+ EVENT_KEYS.add(ek)
166
+ except Exception:
167
+ pass
168
+
169
+
170
+ def load_meta():
171
+ """Return (style_item_ids:set, component_classes:set). Falls back to built-ins.
172
+
173
+ 副作用:第一次调用时从同一份 meta 文件 augment 进 PROP_KEYS / EVENT_KEYS /
174
+ ACTION_TYPES(消除新组件的"未知键"误报)。
175
+ """
176
+ global _META_KEYS_AUGMENTED
177
+ style_ids = set()
178
+ classes = set(COMPONENT_TYPES)
179
+ meta_dir = _meta_dir()
180
+ sm = meta_dir / "style-meta.json"
181
+ cm = meta_dir / "component-meta.json"
182
+ if sm.is_file():
183
+ try:
184
+ data = json.load(open(sm, encoding="utf-8"))
185
+ for grp in data.get("properties", []):
186
+ iid = grp.get("itemId")
187
+ if iid is not None:
188
+ style_ids.add(str(iid))
189
+ except Exception:
190
+ pass
191
+ if cm.is_file():
192
+ try:
193
+ data = json.load(open(cm, encoding="utf-8"))
194
+ comps = data.get("components")
195
+ if isinstance(comps, dict):
196
+ classes = set(comps.keys())
197
+ except Exception:
198
+ pass
199
+ # 第一次调用时顺便 augment 其它白名单(与 classes/style_ids 同源,省一次 IO)
200
+ if not _META_KEYS_AUGMENTED:
201
+ _augment_keys_from_meta(meta_dir)
202
+ _META_KEYS_AUGMENTED = True
203
+ return style_ids, classes
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Helpers
208
+ # ---------------------------------------------------------------------------
209
+ def check_value_from(item, owner, errors, warnings):
210
+ """itemValueFrom 缺失/空串 → 运行时静默丢弃该属性(致命),升级为 ERROR。
211
+
212
+ 根因(property.js L199-224):onPreview 只在 itemValueFrom 为 '0'/'1'/'2' 时才把
213
+ 属性写进预览产物;否则整条分支不执行,该 style/prop 直接消失。gen-card-test 生成时
214
+ 曾因缺 itemValueFrom 导致背景/内边距/gap/对齐全部失效且旧 validate 因 "" 在白名单里漏报。
215
+ """
216
+ value_from = item.get("itemValueFrom")
217
+ if value_from is None or value_from == "":
218
+ errors.append(
219
+ f"Missing/empty itemValueFrom on {owner} — runtime silently drops this property "
220
+ f"(background/padding/gap/alignment lost). Set '0'(const)/'1'(var)/'2'(image)."
221
+ )
222
+ return
223
+ if value_from not in ITEM_VALUE_FROM_VALUES:
224
+ warnings.append(f"Unexpected itemValueFrom='{value_from}' in {owner}")
225
+
226
+
227
+ def check_directives(comp, errors, warnings, depth, style_ids):
228
+ cn = comp.get("className", "")
229
+ for directive in comp.get("directives", []):
230
+ key = directive.get("itemKey", "")
231
+ if key and key not in DIRECTIVE_KEYS:
232
+ warnings.append(f"Unknown directive key '{key}' in component '{cn}' at depth {depth}")
233
+ check_value_from(directive, f"directive '{key}' of '{cn}'", errors, warnings)
234
+ if key == "v-show":
235
+ # Correct shape: itemValue:{} + itemChildren.properties[showType, condition]
236
+ iv = directive.get("itemValue")
237
+ if isinstance(iv, dict) and ("showType" in iv or "condition" in iv):
238
+ warnings.append(f"v-show in '{cn}' at depth {depth} puts showType/condition in itemValue; "
239
+ f"use itemChildren.properties instead")
240
+ child_props = directive.get("itemChildren", {}).get("properties", [])
241
+ child_keys = [c.get("itemKey") for c in child_props]
242
+ if "showType" not in child_keys:
243
+ warnings.append(f"v-show directive in '{cn}' at depth {depth} has no showType child")
244
+ show_type = None
245
+ condition = None
246
+ for c in child_props:
247
+ if c.get("itemKey") == "showType":
248
+ show_type = c.get("itemValue")
249
+ if c.get("itemKey") == "condition":
250
+ condition = c.get("itemValue")
251
+ if show_type in ("01", "02"):
252
+ if not isinstance(condition, dict) or not condition.get("expression"):
253
+ warnings.append(f"v-show showType='{show_type}' in '{cn}' at depth {depth} "
254
+ f"missing condition.expression")
255
+ else:
256
+ name = condition.get("name")
257
+ if name is None or not str(name).strip():
258
+ errors.append(f"v-show showType='{show_type}' in '{cn}' at depth {depth} "
259
+ f"condition 缺显示名 name(必填、不能为空;表达式 <15 字符与表达式一致,>=15 字符设合适命名)")
260
+
261
+
262
+ def check_eventactions(comp, warnings, depth):
263
+ cn = comp.get("className", "")
264
+ for action_index, eventaction in enumerate(comp.get("eventactions", [])):
265
+ event_key = eventaction.get("event", {}).get("eventKey", "")
266
+ action_type = eventaction.get("action", {}).get("actionType", "")
267
+ if event_key and event_key not in EVENT_KEYS:
268
+ warnings.append(f"Unknown eventKey '{event_key}' in '{cn}' at depth {depth}[eventactions[{action_index}]]")
269
+ if action_type and action_type not in ACTION_TYPES:
270
+ warnings.append(f"Unknown actionType '{action_type}' in '{cn}' at depth {depth}[eventactions[{action_index}]]")
271
+ if action_type == "customAction":
272
+ properties = eventaction.get("action", {}).get("properties", [])
273
+ has_function = any(prop.get("itemKey") == "function" for prop in properties)
274
+ if not has_function:
275
+ warnings.append(f"customAction in '{cn}' at depth {depth} has no function property")
276
+
277
+
278
+ def load_style_maps():
279
+ """从 style-meta.json 构建 groupKey -> {childKey: childItemId} 映射(只跑一次)。
280
+
281
+ 用于校验「itemId 存在但放错组」的隐形 bug:例如 flex 组的 flexDirection 子项
282
+ 误用 itemId '002'(实为 alignItems),运行时 restoreProperty 按 itemId 先匹配到
283
+ alignItems 的 meta,导致 flexDirection 永远取默认值(row),Column 不堆叠。旧
284
+ validate 因 '002' 全局存在而漏报;本映射能精确识别。
285
+ """
286
+ global _STYLE_MAPS_LOADED, STYLE_GROUP_CHILD_MAP, STYLE_KEYS, STYLE_SUB_KEYS
287
+ if _STYLE_MAPS_LOADED:
288
+ return
289
+ meta_dir = _meta_dir()
290
+ sm = meta_dir / "style-meta.json"
291
+ if sm.is_file():
292
+ try:
293
+ data = json.load(open(sm, encoding="utf-8"))
294
+ for grp in data.get("properties", []):
295
+ gk = grp.get("itemKey")
296
+ if not gk:
297
+ continue
298
+ cm = {}
299
+ for c in grp.get("itemChildren", []) or []:
300
+ ck = c.get("itemKey"); cid = c.get("itemId")
301
+ if ck is not None and cid is not None:
302
+ cm[str(ck)] = str(cid)
303
+ STYLE_GROUP_CHILD_MAP[str(gk)] = cm
304
+ STYLE_KEYS.add(str(gk))
305
+ STYLE_SUB_KEYS.update(cm.keys())
306
+ except Exception:
307
+ pass
308
+ _STYLE_MAPS_LOADED = True
309
+
310
+
311
+ def check_style_props(style, cn, loc, errors, warnings, style_ids=None):
312
+ """样式组 / 子项校验(Root 与普通节点共用)。
313
+
314
+ 历史上这段只在 check_components 里跑,Root(view)的 style 从不过检 ——
315
+ 实测事故:模型给 Root 背景猜了 gradientColor / gradientDirection(style-meta 无此键,
316
+ itemId 落空 → 运行时静默丢弃),子节点会 ERROR、Root 一声不响,模型连试 3 轮渐变全失效
317
+ 都拿不到任何反馈,最后编了个「渲染器顶部蓝条是测量噪声」的理由收工。
318
+ """
319
+ if style_ids is None:
320
+ style_ids, _ = load_meta()
321
+ load_style_maps()
322
+ if not style:
323
+ return
324
+ for prop in style.get("properties", []):
325
+ sk = prop.get("itemKey", "")
326
+ iid = prop.get("itemId")
327
+ check_value_from(prop, f"style '{sk}' of '{cn}'", errors, warnings)
328
+ if sk and sk not in STYLE_KEYS:
329
+ warnings.append(f"Unknown style key '{sk}' in component '{cn}' {loc}")
330
+ if iid is not None and style_ids and str(iid) not in style_ids:
331
+ warnings.append(f"Style itemId '{iid}' (key '{sk}') in '{cn}' not in style-meta groups")
332
+ children = prop.get("itemChildren", {}).get("properties", [])
333
+ cmap = STYLE_GROUP_CHILD_MAP.get(sk)
334
+ for child in children:
335
+ ck = child.get("itemKey", "")
336
+ cv = child.get("itemValue")
337
+ if ck and ck not in STYLE_SUB_KEYS:
338
+ warnings.append(f"Unknown style sub-key '{ck}' in '{sk}' of '{cn}' {loc}")
339
+ # 样式子项 itemId 必须与 style-meta 中该组的预期 itemId 一致
340
+ if cmap and ck in cmap and child.get("itemId") is not None \
341
+ and str(child.get("itemId")) != cmap[ck]:
342
+ errors.append(
343
+ f"Style '{sk}.{ck}' in '{cn}' {loc} has itemId "
344
+ f"'{child.get('itemId')}' but style-meta expects '{cmap[ck]}' "
345
+ f"(common bug: flexDirection uses 002 instead of 006)"
346
+ )
347
+ # 空 itemId(缩写键 / 自造键未命中 style-meta)→ 运行时静默丢弃,必须 ERROR
348
+ if child.get("itemId") is not None and str(child.get("itemId")).strip() == "":
349
+ errors.append(
350
+ f"Style '{sk}.{ck}' in '{cn}' {loc} has empty itemId "
351
+ f"→ 运行时静默丢弃该样式(缩写键 / 自造键需用 style-meta 原文 itemKey)"
352
+ )
353
+ if sk == "flex" and ck in FLEX_ENUMS:
354
+ if isinstance(cv, str) and cv not in FLEX_ENUMS[ck]:
355
+ errors.append(f"Illegal value for flex.{ck} in '{cn}' {loc}: "
356
+ f"'{cv}'. Use one of {sorted(FLEX_ENUMS[ck])}")
357
+
358
+
359
+ def check_components(components, errors, warnings, depth=0, style_ids=None, seen_ids=None, stats=None):
360
+ if style_ids is None:
361
+ style_ids, _ = load_meta()
362
+ load_style_maps()
363
+ if seen_ids is None:
364
+ seen_ids = set()
365
+ if stats is None:
366
+ stats = {"var_props": 0}
367
+ if depth > 30:
368
+ warnings.append("Component tree depth exceeds 30, possible infinite nesting?")
369
+ return
370
+ for i, comp in enumerate(components):
371
+ if not isinstance(comp, dict):
372
+ errors.append(f"Component at depth {depth}[{i}] is not an object")
373
+ continue
374
+
375
+ cn = comp.get("className", "")
376
+ if not cn:
377
+ errors.append(f"Component at depth {depth}[{i}] missing 'className'")
378
+ elif cn not in COMPONENT_TYPES:
379
+ errors.append(f"Unknown className '{cn}' at depth {depth}[{i}] → 渲染空白(R28)")
380
+
381
+ cid = comp.get("componentId")
382
+ if not cid:
383
+ errors.append(f"Component '{cn}' at depth {depth}[{i}] missing 'componentId'")
384
+ else:
385
+ if cid in seen_ids:
386
+ errors.append(f"Duplicate componentId '{cid}' in '{cn}' at depth {depth}[{i}]")
387
+ seen_ids.add(cid)
388
+
389
+ check_directives(comp, errors, warnings, depth, style_ids)
390
+ check_eventactions(comp, warnings, depth)
391
+
392
+ # Style keys
393
+ check_style_props(comp.get("style", {}), cn, f"at depth {depth}[{i}]", errors, warnings, style_ids)
394
+
395
+ # Property keys
396
+ for prop in comp.get("properties", []):
397
+ pk = prop.get("itemKey", "")
398
+ check_value_from(prop, f"property '{pk}' of '{cn}'", errors, warnings)
399
+ if prop.get("itemValueFrom") in ("1", 1):
400
+ stats["var_props"] = stats.get("var_props", 0) + 1
401
+ if pk and pk not in PROP_KEYS and not pk.startswith("_"):
402
+ warnings.append(f"Unknown property key '{pk}' in component '{cn}' at depth {depth}[{i}]")
403
+ # itemValueFrom "1" (variable/expression) MUST carry itemDefaultValue
404
+ # (the fallback used by the preview when the binding is undefined).
405
+ # itemValueFrom "2" is an image/file value and needs no default.
406
+ if prop.get("itemValueFrom") in ("1", 1) and "itemDefaultValue" not in prop:
407
+ errors.append(f"Property '{pk}' in '{cn}' at depth {depth}[{i}] has itemValueFrom="
408
+ f"'{prop.get('itemValueFrom')}' but no itemDefaultValue")
409
+
410
+ # Slots / children
411
+ if cn in CONTAINER_TYPES or cn == "Select":
412
+ slots = comp.get("slots", [])
413
+ if not slots:
414
+ warnings.append(f"Container '{cn}' at depth {depth}[{i}] has no slots")
415
+ else:
416
+ for slot in slots:
417
+ for child in slot.get("components", []):
418
+ check_components([child], errors, warnings, depth + 1, style_ids, seen_ids)
419
+ else:
420
+ slots = comp.get("slots", [])
421
+ if slots:
422
+ for slot in slots:
423
+ if slot.get("components"):
424
+ warnings.append(f"Leaf component '{cn}' at depth {depth}[{i}] has child components")
425
+
426
+
427
+ def check_data_json(schema, path, warnings):
428
+ schema_dir = os.path.dirname(path)
429
+ data_path = os.path.join(schema_dir, "data.json")
430
+ if not os.path.isfile(data_path):
431
+ return
432
+ try:
433
+ data = json.load(open(data_path, encoding="utf-8"))
434
+ except Exception as e:
435
+ warnings.append(f"data.json beside schema is not valid JSON: {e}")
436
+ return
437
+ params = schema.get("dictionary", {}).get("inputParams", [])
438
+ expected = {p["key"]: p.get("defaultValue") for p in params if "key" in p}
439
+ if set(expected.keys()) != set(data.keys()):
440
+ only_in_schema = sorted(set(expected) - set(data))
441
+ only_in_data = sorted(set(data) - set(expected))
442
+ msg = "data.json key set differs from inputParams"
443
+ if only_in_schema:
444
+ msg += f"; missing in data.json: {only_in_schema}"
445
+ if only_in_data:
446
+ msg += f"; extra in data.json: {only_in_data}"
447
+ warnings.append(msg)
448
+ else:
449
+ for k in expected:
450
+ if k in DATA_JSON_TOLERATED_KEYS:
451
+ continue
452
+ if expected[k] != data.get(k):
453
+ warnings.append(f"data.json['{k}'] != inputParams default (schema={expected[k]!r}, data={data.get(k)!r})")
454
+
455
+
456
+ def validate_schema(path, check_data=True):
457
+ """Return (ok: bool, errors: list, warnings: list)."""
458
+ errors = []
459
+ warnings = []
460
+
461
+ try:
462
+ with open(path, 'r', encoding='utf-8') as f:
463
+ schema = json.load(f)
464
+ except Exception as e:
465
+ return False, [f"Cannot read/parse schema: {e}"], warnings
466
+
467
+ top_level = {"version", "pageId", "name", "dictionary", "view"}
468
+ missing = top_level - set(schema.keys())
469
+ if missing:
470
+ errors.append(f"Missing top-level fields: {', '.join(sorted(missing))}")
471
+
472
+ if "version" in schema and not isinstance(schema["version"], str):
473
+ errors.append("'version' must be a string")
474
+
475
+ params = schema.get("dictionary", {}).get("inputParams", [])
476
+ if not params:
477
+ warnings.append("No inputParams defined")
478
+ else:
479
+ required_fields = ["key", "name", "type", "defaultValue", "desc", "rule", "children"]
480
+ valid_types = {"boolean", "string", "number", "object", "array"}
481
+ for i, p in enumerate(params):
482
+ for field in required_fields:
483
+ if field not in p:
484
+ errors.append(f"inputParams[{i}] missing '{field}' (key={p.get('key', '?')})")
485
+ if "type" in p and p["type"] not in valid_types:
486
+ errors.append(f"inputParams[{i}] type='{p['type']}' is not valid")
487
+ if "rule" in p and not isinstance(p["rule"], dict):
488
+ errors.append(f"inputParams[{i}] rule must be an object (key={p.get('key', '?')})")
489
+ if "children" in p and not isinstance(p["children"], list):
490
+ errors.append(f"inputParams[{i}] children must be an array (key={p.get('key', '?')})")
491
+
492
+ view = schema.get("view")
493
+ style_ids, _ = load_meta()
494
+ if not view:
495
+ errors.append("Missing 'view' (component tree root)")
496
+ elif not isinstance(view, dict):
497
+ errors.append("'view' must be an object")
498
+ else:
499
+ if view.get("className") != "Root":
500
+ warnings.append(f"view.className is '{view.get('className')}', expected 'Root'")
501
+ # 铁律 rules.md §九:Root(view)宽度由顶层 view.width 承载,style 里不放 rect 组(width)。
502
+ # ERROR 级硬卡点(2026-08-17):root style 含 rect → 直接拦截(生成器/手写均防)。
503
+ root_style_keys = {g.get("itemKey") for g in (view.get("style") or {}).get("properties", [])}
504
+ if "rect" in root_style_keys:
505
+ errors.append(
506
+ "view.style must NOT contain 'rect' group (width) — Root 宽度由顶层 view.width 承载,"
507
+ "style 里放 rect 组是冗余且冲突(铁律 rules.md §九;check_topology 亦报 Root_style_rect_forbidden)"
508
+ )
509
+ # Root 的 style 子项同样要过检(否则自造键 / 类型开关缺失在根节点上完全无声)
510
+ check_style_props(view.get("style", {}), view.get("className") or "Root",
511
+ "at view (Root)", errors, warnings, style_ids)
512
+ if "components" not in view:
513
+ errors.append("view has no 'components'")
514
+ else:
515
+ stats = {"var_props": 0}
516
+ check_components(view.get("components", []), errors, warnings, style_ids=style_ids, stats=stats)
517
+ # 数据绑定(itemValueFrom='1')必须声明在 dictionary.inputParams 中,
518
+ # 否则数据字典为空、绑定无法解析、绑定树无法编辑。与「无绑定的卡本就无 inputParams」
519
+ # 区分开:后者仅 warning,前者是 error(校验失败,CI 卡点拦截)。
520
+ if stats["var_props"] > 0 and not params:
521
+ errors.append(
522
+ f"Schema declares {stats['var_props']} variable-binding(s) (itemValueFrom='1') "
523
+ f"but dictionary.inputParams is empty — declare each bound key in "
524
+ f"dictionary.inputParams so the data dictionary and bindings resolve correctly"
525
+ )
526
+
527
+ if check_data:
528
+ check_data_json(schema, path, warnings)
529
+
530
+ ok = len(errors) == 0
531
+ return ok, errors, warnings
532
+
533
+
534
+ def _print_result(path, ok, errors, warnings):
535
+ if errors:
536
+ print(f"\n[FAIL] {path}: {len(errors)} error(s), {len(warnings)} warning(s):")
537
+ for e in errors:
538
+ print(f" [ERROR] {e}")
539
+ for w in warnings:
540
+ print(f" [WARN] {w}")
541
+ elif warnings:
542
+ print(f"\n[OK] {path}: no errors, {len(warnings)} warning(s):")
543
+ for w in warnings:
544
+ print(f" [WARN] {w}")
545
+ else:
546
+ print(f"\n[OK] {path}: schema is valid")
547
+
548
+
549
+ def main():
550
+ args = sys.argv[1:]
551
+ if not args:
552
+ print("Usage: python3 validate_schema.py <schema.json> [--no-data] [--json] [--topology]")
553
+ sys.exit(1)
554
+
555
+ positional = [a for a in args if not a.startswith("--")]
556
+ flags = set(a for a in args if a.startswith("--"))
557
+ if not positional:
558
+ print("Usage: python3 validate_schema.py <schema.json> [--no-data] [--json] [--topology]")
559
+ sys.exit(1)
560
+
561
+ path = positional[0]
562
+ check_data = "--no-data" not in flags
563
+ as_json = "--json" in flags
564
+ check_topo = "--topology" in flags
565
+
566
+ if not os.path.isfile(path):
567
+ if as_json:
568
+ print(json.dumps(
569
+ {"ok": False, "errors": [f"File not found: {path}"], "warnings": []},
570
+ ensure_ascii=False,
571
+ ))
572
+ else:
573
+ print(f"[ERROR] File not found: {path}")
574
+ sys.exit(1)
575
+
576
+ t0 = time.time()
577
+ ok, errors, warnings = validate_schema(path, check_data=check_data)
578
+ duration_ms = int((time.time() - t0) * 1000)
579
+
580
+ # 可选拓扑合规性检查(基于人力卡统计规则库)。
581
+ # 设计为纯建议层:只在 --topology flag 开启时跑,不修改 ok 判定,不影响默认调用。
582
+ topology_hints = []
583
+ if check_topo:
584
+ try:
585
+ # 同目录下的 check_topology.py
586
+ topo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "check_topology.py")
587
+ if os.path.isfile(topo_path):
588
+ # import 一次(脚本目录已在 sys.path 中作为 '')
589
+ import importlib.util
590
+ spec = importlib.util.spec_from_file_location("check_topology", topo_path)
591
+ mod = importlib.util.module_from_spec(spec)
592
+ spec.loader.exec_module(mod)
593
+ hints, err = mod.check_all(path)
594
+ if err:
595
+ topology_hints.append({"type": "topology_check_error", "message": err})
596
+ else:
597
+ topology_hints = hints
598
+ except Exception as e:
599
+ topology_hints.append({"type": "topology_check_error", "message": str(e)})
600
+
601
+ if as_json:
602
+ # 机器可读输出:供 dev-server 中间件 / MCP / CI 直接消费。
603
+ # 现有人读输出在缺 --json 时保持不变。
604
+ out = {
605
+ "path": path,
606
+ "ok": ok,
607
+ "errors": errors,
608
+ "warnings": warnings,
609
+ "durationMs": duration_ms,
610
+ }
611
+ if check_topo:
612
+ out["topologyHints"] = topology_hints
613
+ print(json.dumps(out, ensure_ascii=False))
614
+ else:
615
+ _print_result(path, ok, errors, warnings)
616
+ if check_topo and topology_hints:
617
+ print(f"\n [TOPOLOGY HINTS] {len(topology_hints)} hint(s) vs 人力卡范式:")
618
+ for h in topology_hints:
619
+ print(f" [{h.get('type','?')}] {h.get('suggestion','')}")
620
+ sys.exit(0 if ok else 1)
621
+
622
+
623
+ if __name__ == "__main__":
624
+ main()